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
+29 -15
View File
@@ -27,6 +27,18 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
// ── Types ──
export interface ApplyDomSelectionOptions {
revealPanel?: boolean;
additive?: boolean;
preserveGroup?: boolean;
}
export interface ResolveDomSelectionOptions {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
}
export interface UseDomSelectionParams {
projectId: string | null;
activeCompPath: string | null;
@@ -61,29 +73,17 @@ export interface UseDomSelectionReturn {
// Callbacks
applyDomSelection: (
selection: DomEditSelection | null,
options?: {
revealPanel?: boolean;
additive?: boolean;
preserveGroup?: boolean;
},
options?: ApplyDomSelectionOptions,
) => void;
clearDomSelection: () => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
options?: ResolveDomSelectionOptions,
) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
options?: ResolveDomSelectionOptions,
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
@@ -130,6 +130,7 @@ export function useDomSelection({
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
const compositionIdentityRef = useRef({ activeCompPath, projectId });
// Keep refs in sync with state
domEditSelectionRef.current = domEditSelection;
@@ -230,6 +231,8 @@ export function useDomSelection({
// the user isn't left with an out-of-scope element selected.
const setActiveGroupElement = useCallback(
(el: HTMLElement | null) => {
if (activeGroupElementRef.current === el) return;
activeGroupElementRef.current = el;
setActiveGroupElementState(el);
applyDomSelection(null, { revealPanel: false });
},
@@ -454,6 +457,17 @@ export function useDomSelection({
updateDomEditHoverSelection(null);
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
// Clear committed selection only when the composition identity actually changes.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
const previous = compositionIdentityRef.current;
if (previous.activeCompPath === activeCompPath && previous.projectId === projectId) return;
compositionIdentityRef.current = { activeCompPath, projectId };
activeGroupElementRef.current = null;
setActiveGroupElementState(null);
applyDomSelection(null, { revealPanel: false });
}, [activeCompPath, projectId, applyDomSelection]);
// Clear hover conditionally (caption mode, matches selection, disconnected element)
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {