mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user