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:
Miguel Ángel
2026-07-03 17:41:01 -07:00
committed by GitHub
parent 4d199c0f3a
commit 02e9d6142d
11 changed files with 831 additions and 52 deletions
@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
import { useDomSelection } from "./useDomSelection";
installReactActEnvironment();
interface HarnessProps {
activeCompPath: string | null;
projectId: string | null;
refreshKey: number;
}
function renderHarness(initialProps: HarnessProps): {
current: () => ReturnType<typeof useDomSelection>;
rerender: (props: HarnessProps) => void;
cleanup: () => void;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
let currentHook: ReturnType<typeof useDomSelection> | null = null;
function Harness(props: HarnessProps) {
currentHook = useDomSelection({
projectId: props.projectId,
activeCompPath: props.activeCompPath,
isMasterView: false,
compIdToSrc: new Map(),
captionEditMode: false,
previewIframeRef: { current: null },
timelineElements: [],
setSelectedTimelineElementId: vi.fn(),
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
previewIframe: null,
refreshKey: props.refreshKey,
rightPanelTab: "design",
});
return null;
}
const rerender = (props: HarnessProps) => {
act(() => {
root.render(React.createElement(Harness, props));
});
};
rerender(initialProps);
return {
current: () => {
if (!currentHook) throw new Error("Expected hook result");
return currentHook;
},
rerender,
cleanup: () => {
act(() => root.unmount());
host.remove();
},
};
}
function setupSelectedHarness() {
const element = document.createElement("div");
element.id = "headline";
const selection = makeSelection("Headline", element);
const harness = renderHarness({
activeCompPath: "intro.html",
projectId: "project-1",
refreshKey: 0,
});
act(() => harness.current().applyDomSelection(selection));
return { selection, harness };
}
describe("useDomSelection", () => {
it("clears a committed selection when the active composition path changes", () => {
const { selection, harness } = setupSelectedHarness();
expect(harness.current().domEditSelection).toBe(selection);
harness.rerender({
activeCompPath: "outro.html",
projectId: "project-1",
refreshKey: 0,
});
expect(harness.current().domEditSelection).toBe(null);
harness.cleanup();
});
it("preserves a committed selection when composition identity is unchanged", () => {
const { selection, harness } = setupSelectedHarness();
harness.rerender({
activeCompPath: "intro.html",
projectId: "project-1",
refreshKey: 1,
});
expect(harness.current().domEditSelection).toBe(selection);
harness.cleanup();
});
it("preserves selection when clearing an already inactive group scope", () => {
const { selection, harness } = setupSelectedHarness();
act(() => harness.current().setActiveGroupElement(null));
expect(harness.current().domEditSelection).toBe(selection);
harness.cleanup();
});
it("preserves selection when entering the already active group scope", () => {
const group = document.createElement("div");
const child = document.createElement("span");
child.id = "headline";
group.append(child);
const selection = makeSelection("Headline", child);
const harness = renderHarness({
activeCompPath: "intro.html",
projectId: "project-1",
refreshKey: 0,
});
act(() => harness.current().setActiveGroupElement(group));
act(() => harness.current().applyDomSelection(selection));
act(() => harness.current().setActiveGroupElement(group));
expect(harness.current().domEditSelection).toBe(selection);
harness.cleanup();
});
});