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
@@ -345,6 +345,101 @@ describe("DomEditOverlay", () => {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
host.remove();
});
it("passes the tracked hover selection when clicking the existing selection box", async () => {
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function (): DOMRect {
return {
left: 0,
top: 0,
right: 800,
bottom: 450,
width: 800,
height: 450,
x: 0,
y: 0,
toJSON: () => ({}),
};
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const selection: DomEditSelection = {
element: document.createElement("div"),
id: "hero-title",
selector: ".hero-title",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Hero Title",
textContent: "Hello",
textFields: [],
capabilities: {
canEditText: true,
canEditLayout: true,
canMove: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: {
display: "block",
position: "absolute",
},
};
const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" };
const onCanvasMouseDown = vi.fn();
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
function Harness() {
return React.createElement(DomEditOverlay, {
...createOverlayProps({
iframeRef,
selection,
hoverSelection,
onSelectionChange: () => {},
}),
onCanvasMouseDown,
});
}
act(() => {
root.render(React.createElement(Harness));
});
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
});
const selectionBox = host.querySelector(
'[data-dom-edit-selection-box="true"]',
) as HTMLDivElement;
expect(selectionBox).toBeTruthy();
act(() => {
selectionBox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onCanvasMouseDown).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hoverSelection }),
);
act(() => {
root.unmount();
});
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
host.remove();
});
});
describe("resolveDomEditCoordinateScale", () => {
@@ -1,6 +1,7 @@
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
@@ -44,7 +45,7 @@ interface DomEditOverlayProps {
allowCanvasMovement?: boolean;
onCanvasMouseDown: (
event: React.MouseEvent<HTMLDivElement>,
options?: { preferClipAncestor?: boolean },
options?: PreviewMouseDownOptions,
) => void;
onCanvasPointerMove: (
event: React.PointerEvent<HTMLDivElement>,
@@ -277,6 +278,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
iframeRef,
boxRef,
selectionRef,
hoverSelectionRef,
overlayRectRef,
groupOverlayItemsRef,
gestureRef,
@@ -336,7 +338,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
// Allow clicks anywhere on the overlay — GSAP-translated elements can
// extend beyond the composition rect into the gray zone, and users need
// to select/deselect them by clicking there.
onCanvasMouseDown(event, { preferClipAncestor: false });
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
suppressNextBoxClickRef.current = true;
@@ -401,7 +403,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
event.stopPropagation();
return;
}
onCanvasMouseDown(event, { preferClipAncestor: false });
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
};
const suppressBoxMouseDown = (e: React.MouseEvent) => {
@@ -9,6 +9,7 @@ import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
import type { SnapGuidesState } from "./SnapGuideOverlay";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
export type GestureKind = "drag" | "resize" | "rotate";
@@ -161,6 +162,7 @@ export type UseDomEditOverlayGesturesOptions = {
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
hoverSelectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
@@ -194,9 +196,6 @@ export type UseDomEditOverlayGesturesOptions = {
o?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
onCanvasMouseDown: (e: React.MouseEvent<HTMLDivElement>, o?: PreviewMouseDownOptions) => void;
snapGuidesRef: RefObject<SnapGuidesState | null>;
};
@@ -369,6 +369,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
opts.suppressNextBoxClickRef.current = true;
opts.onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
preferClipAncestor: false,
hoverSelection: opts.hoverSelectionRef.current,
});
return;
}