mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user