refactor(studio): keep the selection files under the size cap

The selection work above pushed four files past the 600-line gate. Same
split the branch made later, landed with the changes that caused it.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-09 19:10:56 -04:00
parent 0de5a0f772
commit 44a26909ae
4 changed files with 117 additions and 87 deletions
@@ -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<HTMLDivElement>) => {
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;
@@ -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<T extends { element: Element }>(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;
@@ -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<string>) => 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 });
}
+8 -49
View File
@@ -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],
);