mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
* test(studio): add design-panel QA fixture and triage matrix Fixture project covering all panel-editable element archetypes, plus the QA findings matrix from the design-panel bug campaign. * fix(studio): make canvas selection hit intended elements - honor author pointer-events:none in hit-testing (was selecting invisible overlays) - pause playback before mousedown sampling; fall back to hover selection on null resolve - invalidate committed selection when the active composition changes - double-click keeps selection and defers to multi-candidate click cycling * fix(studio): close remaining selection-layer review findings - hoverSelection fallback now wired at all 3 mousedown call sites (box-click, blocked-drag, plain overlay click) instead of just the overlay path - pointer-events override detection reads computed style, not inline style, so a CSS-class opt-in (not just inline style=) on a descendant is honored - defensively remove the pointer-events override before the group-fallback check too, closing a theoretical gap in the no-elementsFromPoint branch - a click that resolves to nothing (dead-zone / deselect) no longer leaves playback paused if it was already playing
261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
import { usePlayerStore } from "../player";
|
|
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
|
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
|
|
installReactActEnvironment();
|
|
|
|
function createPreviewIframe(playerPause?: () => void): HTMLIFrameElement {
|
|
const iframe = document.createElement("iframe");
|
|
if (playerPause) {
|
|
Object.defineProperty(iframe, "contentWindow", {
|
|
configurable: true,
|
|
value: {
|
|
__player: {
|
|
getTime: () => 3.25,
|
|
pause: playerPause,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
return iframe;
|
|
}
|
|
|
|
interface HarnessArgs {
|
|
previewIframe: HTMLIFrameElement | null;
|
|
resolveDomSelectionFromPreviewPoint: (
|
|
clientX: number,
|
|
clientY: number,
|
|
options?: { activeGroupElement?: HTMLElement | null },
|
|
) => Promise<DomEditSelection | null>;
|
|
resolveAllDomSelectionsFromPreviewPoint?: (
|
|
clientX: number,
|
|
clientY: number,
|
|
) => Promise<DomEditSelection[]>;
|
|
applyDomSelection: (
|
|
selection: DomEditSelection | null,
|
|
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
) => void;
|
|
setActiveGroupElement?: (el: HTMLElement | null) => void;
|
|
mouseDownOptions?: {
|
|
preferClipAncestor?: boolean;
|
|
hoverSelection?: DomEditSelection | null;
|
|
};
|
|
}
|
|
|
|
function renderHarness(args: HarnessArgs): {
|
|
canvas: HTMLDivElement;
|
|
cleanup: () => void;
|
|
} {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
|
|
function Harness() {
|
|
const interaction = usePreviewInteraction({
|
|
captionEditMode: false,
|
|
compositionLoading: false,
|
|
previewIframeRef: { current: args.previewIframe },
|
|
showToast: vi.fn(),
|
|
applyDomSelection: args.applyDomSelection,
|
|
resolveDomSelectionFromPreviewPoint: args.resolveDomSelectionFromPreviewPoint,
|
|
resolveAllDomSelectionsFromPreviewPoint:
|
|
args.resolveAllDomSelectionsFromPreviewPoint ?? vi.fn(async () => []),
|
|
updateDomEditHoverSelection: vi.fn(),
|
|
setActiveGroupElement: args.setActiveGroupElement ?? vi.fn(),
|
|
});
|
|
|
|
return React.createElement("div", {
|
|
id: "canvas",
|
|
onMouseDown: (event: React.MouseEvent<HTMLDivElement>) => {
|
|
void interaction.handlePreviewCanvasMouseDown(event, args.mouseDownOptions);
|
|
},
|
|
});
|
|
}
|
|
|
|
act(() => {
|
|
root.render(React.createElement(Harness));
|
|
});
|
|
|
|
const canvas = host.querySelector("#canvas");
|
|
if (!(canvas instanceof HTMLDivElement)) throw new Error("Expected canvas div");
|
|
|
|
return {
|
|
canvas,
|
|
cleanup: () => {
|
|
act(() => root.unmount());
|
|
host.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function dispatchMouseDown(canvas: HTMLDivElement, init: MouseEventInit): Promise<void> {
|
|
await act(async () => {
|
|
canvas.dispatchEvent(
|
|
new MouseEvent("mousedown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: 50,
|
|
clientY: 60,
|
|
...init,
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe("usePreviewInteraction", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("pauses playback before resolving a click and falls back to the tracked hover selection", async () => {
|
|
const order: string[] = [];
|
|
const playerPause = vi.fn(() => {
|
|
order.push("pause");
|
|
});
|
|
const element = document.createElement("div");
|
|
element.id = "headline";
|
|
const hoverSelection = makeSelection("Headline", element);
|
|
const applyDomSelection = vi.fn();
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => {
|
|
order.push("resolve");
|
|
return null;
|
|
});
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: createPreviewIframe(playerPause),
|
|
resolveDomSelectionFromPreviewPoint,
|
|
applyDomSelection,
|
|
mouseDownOptions: { preferClipAncestor: false, hoverSelection },
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, {});
|
|
|
|
expect(order[0]).toBe("pause");
|
|
expect(applyDomSelection).toHaveBeenCalledWith(hoverSelection);
|
|
cleanup();
|
|
});
|
|
|
|
it("treats a double-click on a regular element as a plain selection", async () => {
|
|
const element = document.createElement("div");
|
|
element.id = "headline";
|
|
const selection = makeSelection("Headline", element);
|
|
const applyDomSelection = vi.fn();
|
|
let resolveCount = 0;
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => {
|
|
resolveCount += 1;
|
|
return resolveCount === 1 ? selection : null;
|
|
});
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: null,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
applyDomSelection,
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, { detail: 2 });
|
|
|
|
expect(applyDomSelection).toHaveBeenCalledWith(selection);
|
|
expect(applyDomSelection).not.toHaveBeenCalledWith(null, { revealPanel: false });
|
|
cleanup();
|
|
});
|
|
|
|
it("preserves group drill-in on double-click", async () => {
|
|
const group = document.createElement("div");
|
|
group.setAttribute("data-hf-group", "hero");
|
|
const child = document.createElement("span");
|
|
child.id = "headline";
|
|
group.append(child);
|
|
const groupSelection = makeSelection("Hero Group", group);
|
|
const childSelection = makeSelection("Headline", child);
|
|
const applyDomSelection = vi.fn();
|
|
const setActiveGroupElement = vi.fn();
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(
|
|
async (
|
|
_clientX: number,
|
|
_clientY: number,
|
|
options?: { activeGroupElement?: HTMLElement | null },
|
|
) => (options?.activeGroupElement === group ? childSelection : groupSelection),
|
|
);
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: null,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
applyDomSelection,
|
|
setActiveGroupElement,
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, { detail: 2 });
|
|
|
|
expect(setActiveGroupElement).toHaveBeenCalledWith(group);
|
|
expect(applyDomSelection).toHaveBeenCalledWith(childSelection);
|
|
cleanup();
|
|
});
|
|
|
|
it("cycles stacked candidates on a rapid second click at the same spot", async () => {
|
|
const topElement = document.createElement("div");
|
|
topElement.id = "top";
|
|
const bottomElement = document.createElement("div");
|
|
bottomElement.id = "bottom";
|
|
const topSelection = makeSelection("Top", topElement);
|
|
const bottomSelection = makeSelection("Bottom", bottomElement);
|
|
const applyDomSelection = vi.fn();
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => topSelection);
|
|
const resolveAllDomSelectionsFromPreviewPoint = vi.fn(async () => [
|
|
topSelection,
|
|
bottomSelection,
|
|
]);
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: null,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
applyDomSelection,
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, { detail: 1 });
|
|
await dispatchMouseDown(canvas, { detail: 2 });
|
|
|
|
expect(applyDomSelection).toHaveBeenNthCalledWith(1, topSelection);
|
|
expect(applyDomSelection).toHaveBeenNthCalledWith(2, bottomSelection);
|
|
cleanup();
|
|
});
|
|
|
|
it("resumes playback when a click resolves to nothing (dead-zone / deselect)", async () => {
|
|
usePlayerStore.setState({ isPlaying: true });
|
|
const applyDomSelection = vi.fn();
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: createPreviewIframe(vi.fn()),
|
|
resolveDomSelectionFromPreviewPoint,
|
|
applyDomSelection,
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, {});
|
|
|
|
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
cleanup();
|
|
});
|
|
|
|
it("does not resume playback on deselect when it was already paused", async () => {
|
|
usePlayerStore.setState({ isPlaying: false });
|
|
const applyDomSelection = vi.fn();
|
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
|
|
const { canvas, cleanup } = renderHarness({
|
|
previewIframe: createPreviewIframe(vi.fn()),
|
|
resolveDomSelectionFromPreviewPoint,
|
|
applyDomSelection,
|
|
});
|
|
|
|
await dispatchMouseDown(canvas, {});
|
|
|
|
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
cleanup();
|
|
});
|
|
});
|