Files
hyperframes/packages/studio/src/hooks/usePreviewInteraction.test.ts
T
Miguel Ángel 02e9d6142d fix(studio): make canvas selection hit intended elements (#1907)
* 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
2026-07-03 17:41:01 -07:00

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();
});
});