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
@@ -3,6 +3,7 @@ import { liveTime, usePlayerStore } from "../player";
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
import { type DomEditSelection } from "../components/editor/domEditing";
import type { ApplyDomSelectionOptions, ResolveDomSelectionOptions } from "./useDomSelection";
import { trackStudioEvent } from "../utils/studioTelemetry";
// ── Types ──
@@ -16,16 +17,12 @@ export interface UsePreviewInteractionParams {
// From useDomSelection
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
options?: ApplyDomSelectionOptions,
) => void;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: {
preferClipAncestor?: boolean;
skipSourceProbe?: boolean;
activeGroupElement?: HTMLElement | null;
},
options?: ResolveDomSelectionOptions,
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
@@ -46,6 +43,11 @@ interface ClickCycleState {
at: number;
}
export interface PreviewMouseDownOptions {
preferClipAncestor?: boolean;
hoverSelection?: DomEditSelection | null;
}
const CYCLE_RADIUS_PX = 6;
const CYCLE_WINDOW_MS = 600;
// Manual double-click window. `e.detail` can't be trusted here: the first click
@@ -72,9 +74,19 @@ export function usePreviewInteraction({
const cycleRef = useRef<ClickCycleState | null>(null);
const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);
const pausePreviewPlayback = useCallback(() => {
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
const playerStore = usePlayerStore.getState();
playerStore.setIsPlaying(false);
if (pausedTime != null) {
playerStore.setCurrentTime(pausedTime);
liveTime.notify(pausedTime);
}
}, [previewIframeRef]);
const handlePreviewCanvasMouseDown = useCallback(
// fallow-ignore-next-line complexity
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
async (e: React.MouseEvent<HTMLDivElement>, options?: PreviewMouseDownOptions) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
// Manual double-click detection (see DOUBLE_CLICK_MS): the first click
@@ -87,12 +99,26 @@ export function usePreviewInteraction({
downTs - lastDown.t < DOUBLE_CLICK_MS &&
Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };
const wasPlaying = usePlayerStore.getState().isPlaying;
pausePreviewPlayback();
// A click that resolves to nothing (dead-zone / deselect) shouldn't leave
// playback paused — pausing before sampling only exists to keep the hit
// target stable while resolving; resume if nothing was selected.
const resumeIfNothingSelected = () => {
if (wasPlaying) usePlayerStore.getState().setIsPlaying(true);
};
// Double-click a group → drill into it and select the child under the
// pointer (resolve with the group as the explicit drill-in scope, since the
// activeGroupElement state hasn't re-rendered yet within this handler).
if (isDoubleClick && !e.shiftKey) {
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
const cycle = cycleRef.current;
const hasStackCycleAtSpot =
cycle !== null &&
cycle.candidates.length > 1 &&
Math.hypot(e.clientX - cycle.x, e.clientY - cycle.y) < CYCLE_RADIUS_PX &&
downTs - cycle.at < CYCLE_WINDOW_MS;
if (hit?.element.hasAttribute("data-hf-group")) {
e.preventDefault();
e.stopPropagation();
@@ -105,6 +131,18 @@ export function usePreviewInteraction({
applyDomSelection(child ?? hit);
return;
}
if (
hit &&
!hasStackCycleAtSpot &&
!hit.element.hasAttribute("data-composition-src") &&
!hit.element.hasAttribute("data-composition-file")
) {
e.preventDefault();
e.stopPropagation();
cycleRef.current = null;
applyDomSelection(hit);
return;
}
}
const now = Date.now();
@@ -119,10 +157,16 @@ export function usePreviewInteraction({
if (e.shiftKey) {
// Additive selection — no cycling
cycleRef.current = null;
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
});
if (!nextSelection) return;
const nextSelection =
(await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
})) ??
options?.hoverSelection ??
null;
if (!nextSelection) {
resumeIfNothingSelected();
return;
}
e.preventDefault();
e.stopPropagation();
applyDomSelection(nextSelection, { additive: true });
@@ -156,9 +200,11 @@ export function usePreviewInteraction({
activeGroupElement: null,
});
}
nextSelection = nextSelection ?? options?.hoverSelection ?? null;
if (!nextSelection) {
cycleRef.current = null;
applyDomSelection(null, { revealPanel: false });
resumeIfNothingSelected();
return;
}
e.preventDefault();
@@ -180,6 +226,7 @@ export function usePreviewInteraction({
captionEditMode,
compositionLoading,
onClickToSource,
pausePreviewPlayback,
resolveAllDomSelectionsFromPreviewPoint,
resolveDomSelectionFromPreviewPoint,
setActiveGroupElement,
@@ -225,14 +272,8 @@ export function usePreviewInteraction({
);
const handleDomManualDragStart = useCallback(() => {
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
const playerStore = usePlayerStore.getState();
playerStore.setIsPlaying(false);
if (pausedTime != null) {
playerStore.setCurrentTime(pausedTime);
liveTime.notify(pausedTime);
}
}, [previewIframeRef]);
pausePreviewPlayback();
}, [pausePreviewPlayback]);
return {
handlePreviewCanvasMouseDown,