diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index a747b66c7..151c24ce0 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -13,7 +13,7 @@ import { type GestureState, type GroupGestureState, focusDomEditOverlayElement, - hoverCacheDescribesPoint, + resolveShiftClickCandidate, } from "./domEditOverlayGestures"; import { useDomEditOverlayRects } from "./useDomEditOverlayRects"; import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators"; @@ -32,6 +32,7 @@ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh"; import { CanvasContextMenu } from "./CanvasContextMenu"; import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; +import { logSelect } from "../../utils/selectDebug"; // Re-exports for external consumers — preserving existing import paths. export { @@ -44,7 +45,6 @@ export { hasDomEditRotationChanged, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; -import { logSelect } from "../../utils/selectDebug"; export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures"; interface DomEditOverlayProps { @@ -329,11 +329,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({ return; } const target = event.target as HTMLElement | null; - logSelect("mousedown", { - shift: event.shiftKey, - onBox: Boolean(target?.closest('[data-dom-edit-selection-box="true"]')), - }); - if (target?.closest('[data-dom-edit-selection-box="true"]')) return; + const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]')); + logSelect("mousedown", { shift: event.shiftKey, onBox }); + if (onBox) return; // 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. @@ -348,31 +346,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const handleOverlayPointerDown = (event: React.PointerEvent) => { if (!allowCanvasMovement || event.button !== 0) return; if (event.shiftKey) { - // The hover selection is an ASYNC cache, so it can still describe whatever the - // pointer passed over on its way here. Reading it without checking is safe for - // a hover outline and wrong for a shift-click: the click silently adds THAT - // element instead of the one under the pointer, which reads as multi-select - // picking things at random. Confirm the cache is about this point with a - // synchronous hit-test; when it isn't, fall through untouched — no - // preventDefault, no suppression — so the mousedown path below resolves the - // point properly instead of this one guessing. - const candidate = hoverSelectionRef.current; const shiftIframe = iframeRef.current; - const pointTarget = shiftIframe - ? getPreviewTargetFromPointer( - shiftIframe, - event.clientX, - event.clientY, - activeCompositionPathRef.current, - ) - : null; - const cacheIsAboutThisPoint = hoverCacheDescribesPoint(candidate?.element, pointTarget); - logSelect("shift-pointerdown", { - candidate: candidate?.selector ?? candidate?.id ?? null, - pointTarget: pointTarget?.id ?? pointTarget?.tagName ?? null, - cacheIsAboutThisPoint, + const candidate = resolveShiftClickCandidate({ + cached: hoverSelectionRef.current, + elementAtPoint: shiftIframe + ? getPreviewTargetFromPointer( + shiftIframe, + event.clientX, + event.clientY, + activeCompositionPathRef.current, + ) + : null, }); - if (!candidate || !cacheIsAboutThisPoint) return; + // Not confident: fall through untouched — no preventDefault, no suppression — + // so the mousedown path resolves this point instead of guessing here. + if (!candidate) return; event.preventDefault(); event.stopPropagation(); suppressNextOverlayMouseDownRef.current = true; diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 562680f84..34d6519c1 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -10,6 +10,7 @@ import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry"; import type { SnapContext } from "./snapTargetCollection"; import type { SnapGuidesState } from "./SnapGuideOverlay"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; +import { logSelect } from "../../utils/selectDebug"; export type GestureKind = "drag" | "resize" | "rotate"; @@ -112,14 +113,6 @@ export function focusDomEditOverlayElement(element: FocusableDomEditOverlay | nu element?.focus({ preventScroll: true }); } -/** - * Overlay-px translation that keeps the element's CENTER fixed while a corner - * resizes: a CSS width/height change grows the layout box from its top-left, so - * the center drifts by half the size change on each axis; translating back by that - * half-delta re-pins the center. This is the UNROTATED (AABB) fallback used only - * when the element's real transformed corners can't be measured — the primary path - * pins the measured center (rotation-safe) in useDomEditOverlayGestures. - */ /** * Whether the hover cache may stand in for a hit-test at this point. * @@ -139,6 +132,36 @@ export function hoverCacheDescribesPoint( return cachedElement === elementAtPoint || cachedElement.contains(elementAtPoint); } +/** + * The element a shift-click should add, or null to let the slower path resolve it. + * + * Reading the hover cache without checking is safe for a hover outline and wrong + * for a shift-click: the click silently adds whatever the pointer last passed + * over instead of the element under it, which reads as multi-select picking + * things at random. Returning null means "not confident", and the caller must + * then fall through untouched so the mousedown path resolves the point properly. + */ +export function resolveShiftClickCandidate(input: { + cached: T | null; + elementAtPoint: Element | null; +}): T | null { + const describes = hoverCacheDescribesPoint(input.cached?.element, input.elementAtPoint); + logSelect("shift-pointerdown", { + candidate: input.cached ? ((input.cached as { selector?: string }).selector ?? null) : null, + pointTarget: input.elementAtPoint?.id ?? input.elementAtPoint?.tagName ?? null, + cacheIsAboutThisPoint: describes, + }); + return describes ? input.cached : null; +} + +/** + * Overlay-px translation that keeps the element's CENTER fixed while a corner + * resizes: a CSS width/height change grows the layout box from its top-left, so + * the center drifts by half the size change on each axis; translating back by that + * half-delta re-pins the center. This is the UNROTATED (AABB) fallback used only + * when the element's real transformed corners can't be measured — the primary path + * pins the measured center (rotation-safe) in useDomEditOverlayGestures. + */ export function resolveResizeCenterAnchorOffset(input: { originWidth: number; originHeight: number; diff --git a/packages/studio/src/hooks/domSelectionTimelineMirror.ts b/packages/studio/src/hooks/domSelectionTimelineMirror.ts new file mode 100644 index 000000000..346acd1ae --- /dev/null +++ b/packages/studio/src/hooks/domSelectionTimelineMirror.ts @@ -0,0 +1,60 @@ +import type { SelectElementOptions, TimelineElement } from "../player"; +import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { logSelect } from "../utils/selectDebug"; + +interface TimelineMirrorDeps { + timelineElements: TimelineElement[]; + setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void; + setTimelineSelectionSet: (ids: Set) => void; +} + +/** + * Mirror a canvas selection onto the timeline: the whole set first, then the + * primary as its anchor. + * + * The timeline is the source of truth for what is selected and it syncs back — + * whatever it holds replaces the canvas selection a moment later. Announcing only + * the primary therefore drops every other member. Worse, anchoring with + * `preserveSet` on an id the set does not yet contain empties the set outright, + * and an empty set syncs back as "nothing is selected" — which is how adding a + * second element, or moving a group, could wipe the selection instead of keeping + * it. Publishing the members first is what makes the anchor a member, so + * preserving the set is meaningful rather than destructive. + */ +export function announceTimelineSelection( + deps: TimelineMirrorDeps, + group: DomEditSelection[], + primary: DomEditSelection | null, +): void { + const { timelineElements, setSelectedTimelineElementId, setTimelineSelectionSet } = deps; + if (!primary) { + setTimelineSelectionSet(new Set()); + setSelectedTimelineElementId(null); + return; + } + const timelineIdFor = (selection: DomEditSelection) => + findMatchingTimelineElementId(selection, timelineElements) ?? + findTimelineIdByAncestor( + selection.element, + timelineElements, + selection.sourceFile || "index.html", + ); + const members = group.map(timelineIdFor).filter((id): id is string => Boolean(id)); + const anchor = timelineIdFor(primary); + // A member with no timeline row of its own resolves to null and is dropped here, + // so a group can announce fewer ids than it has — or none, which reads back as an + // empty selection and takes the canvas selection with it. + logSelect("announce", { + group: group.length, + published: members.length, + anchor, + anchorPublished: anchor != null && members.includes(anchor), + }); + // Only a real multi-selection publishes members. A single selection keeps the + // older contract on purpose: anchoring with preserveSet holds a live set the + // element already belongs to (a late async primary must not collapse a group) + // and collapses otherwise, which is what a fresh click means. + if (group.length > 1) setTimelineSelectionSet(new Set(members)); + setSelectedTimelineElementId(anchor, { preserveSet: true }); +} diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index dbe7d4b83..e24c34c63 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -4,11 +4,7 @@ import { getAllPreviewTargetsFromPointer, getPreviewTargetFromPointer, } from "../utils/studioPreviewHelpers"; -import { - findMatchingTimelineElementId, - findTimelineIdByAncestor, - type RightPanelTab, -} from "../utils/studioHelpers"; +import { type RightPanelTab } from "../utils/studioHelpers"; import { domEditSelectionsTargetSame, domEditSelectionInGroup, @@ -25,6 +21,7 @@ import { import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; import { useStudioTestHooks } from "./useStudioTestHooks"; import { logSelect } from "../utils/selectDebug"; +import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror"; // ── Types ── @@ -149,51 +146,13 @@ export function useDomSelection({ // ── Callbacks ── - /** - * Mirror a canvas selection onto the timeline: the whole set first, then the - * primary as its anchor. - * - * The timeline is the source of truth for what is selected and it syncs back — - * whatever it holds replaces the canvas selection a moment later. Announcing only - * the primary therefore drops every other member. Worse, anchoring with - * `preserveSet` on an id the set does not yet contain empties the set outright, - * and an empty set syncs back as "nothing is selected" — which is how adding a - * second element, or moving a group, could wipe the selection instead of keeping - * it. Publishing the members first is what makes the anchor a member, so - * preserving the set is meaningful rather than destructive. - */ const announceTimelineSelection = useCallback( - (group: DomEditSelection[], primary: DomEditSelection | null) => { - if (!primary) { - setTimelineSelectionSet(new Set()); - setSelectedTimelineElementId(null); - return; - } - const timelineIdFor = (selection: DomEditSelection) => - findMatchingTimelineElementId(selection, timelineElements) ?? - findTimelineIdByAncestor( - selection.element, - timelineElements, - selection.sourceFile || "index.html", - ); - // Only a real multi-selection publishes members. A single selection keeps the - // older contract on purpose: anchoring with preserveSet holds a live set the - // element already belongs to (a late async primary must not collapse a group) - // and collapses otherwise, which is what a fresh click means. - const members = group.map(timelineIdFor).filter((id): id is string => Boolean(id)); - const anchor = timelineIdFor(primary); - // A member with no timeline row of its own resolves to null and is dropped - // here, so a group can announce fewer ids than it has — or none, which reads - // back as an empty selection and takes the canvas selection with it. - logSelect("announce", { - group: group.length, - published: members.length, - anchor, - anchorPublished: anchor != null && members.includes(anchor), - }); - if (group.length > 1) setTimelineSelectionSet(new Set(members)); - setSelectedTimelineElementId(anchor, { preserveSet: true }); - }, + (group: DomEditSelection[], primary: DomEditSelection | null) => + announceSelectionToTimeline( + { timelineElements, setSelectedTimelineElementId, setTimelineSelectionSet }, + group, + primary, + ), [setSelectedTimelineElementId, setTimelineSelectionSet, timelineElements], );