feat(studio): marquee multi-selection + off-canvas indicators (#1693)

* chore(studio): remove all console.* calls from studio package

* chore(studio): address review — remove dead stubs, restore consent notice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): immediateRender for set tweens + array timeline normalization

- Set tweens now emit immediateRender:true so they render on page load
  without requiring the runtime to seek past position 0
- Runtime IIFE normalizes array timelines (window.__timelines = [tl])
  to keyed objects, and auto-adds data-start on root elements
- Drag teardown clears translate:none to prevent #1673 fly-off
- Position-only set tweens hidden from timeline diamonds (3 cache paths)
- Parser: ease-only keyframe update preserves existing properties

* fix(runtime): address review — restore perf gate, debug surface, scrub restore

- Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on
  restack — avoids ~2400 wasted per-tick seeks on video-heavy renders.
- Restore the console.debug body + docstring bullet of swallow() in
  diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an
  empty if-block.
- Rebind: after the progress-cycle set() kick, seek to state.currentTime via
  totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload
  restore keeps the playhead.
- Array __timelines normalization + data-start default now resolve the root
  via a shared findRootCompositionEl() that honors data-root="true" first
  (matches resolveRootCompositionElement, which now delegates to it).
- Ease-only keyframe update leaves a primitive (non-object) keyframe value
  untouched instead of wiping it to {}; add a preservation unit test.
- Document the boundDuration<=0 progress(1) kick + restore the STATIC-case
  comment in gsapRuntimeBridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): marquee multi-selection + off-canvas indicators

- Click+drag on empty canvas draws dashed selection rectangle
- SAT/OBB intersection handles rotated/scaled/skewed elements
- Shift+marquee adds to existing selection
- Click on empty canvas deselects
- Off-canvas elements show dashed outline indicators (clickable)
- Dashed border only shows outside canvas, solid inside (clip-path)
- 12 geometry unit tests

* feat(studio): address review — group-aware off-canvas indicators + fixes

- Off-canvas indicator suppression now skips every selected element (primary
  AND marquee group members), not just the primary, so group members no longer
  render a doubled overlay (group rect + dashed indicator).
- Drop selection from the off-canvas layout effect deps; the selected-element
  filter runs at render time. Avoids re-walking geometry on each selection change.
- applyMarqueeSelection now honors STUDIO_INSPECTOR_PANELS_ENABLED.
- Restore the stale-selection clear in useDomEditPreviewSync when the selected
  element no longer resolves after a re-sync. Drag-release stays handled by
  suppressNextBoxClickRef.
- Off-canvas indicator is keyboard-accessible; canvas cursor driven by marquee
  rect state, not a render-time ref read.
- Rename partiallyOutside -> extendsOutsideComp + comment the clip-path hit-test.
- Extract OffCanvasIndicators into its own component (DomEditOverlay was already
  over the 600-LOC cap on this branch; extraction brings it under).
- Declare onUpdateKeyframeEase on PropertyPanelProps so this branch typechecks
  standalone (handler + wiring already here; only the type had leaked upstack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-06-24 17:53:18 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6987447a75
commit 8ae010bf51
12 changed files with 801 additions and 51 deletions
@@ -17,7 +17,7 @@ import {
STUDIO_PREVIEW_SELECTION_ENABLED,
} from "./editor/manualEditingAvailability";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
domEditHoverSelection,
domEditSelection,
domEditGroupSelections,
selectedGsapAnimations,
} = useDomEditSelectionContext();
const {
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapDeleteAllForElement,
buildDomSelectionForTimelineElement,
} = useDomEditContext();
applyMarqueeSelection,
} = useDomEditActionsContext();
// fallow-ignore-next-line complexity
const [snapPrefs, setSnapPrefs] = useState(() => {
const p = readStudioUiPreferences();
return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
handleGsapDeleteAllForElement(`#${rawId}`);
},
// fallow-ignore-next-line complexity
onDeleteKeyframe: (_elId: string, pct: number) => {
const cacheKey = domEditSelection?.id ?? "";
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
}
}
},
// fallow-ignore-next-line complexity
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
const currentTime = usePlayerStore.getState().currentTime;
const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
onMarqueeSelect={applyMarqueeSelection}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{STUDIO_KEYFRAMES_ENABLED && (
@@ -121,6 +121,7 @@ export function StudioRightPanel({
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
handleUpdateKeyframeEase,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
@@ -274,6 +275,7 @@ export function StudioRightPanel({
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
@@ -1,7 +1,10 @@
import { memo, useMemo, useRef, useState, type RefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { type DomEditSelection } from "./domEditing";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { useMarqueeGestures } from "./marqueeCommit";
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import { collectDomEditLayerItems } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
@@ -11,6 +14,7 @@ import {
focusDomEditOverlayElement,
} from "./domEditOverlayGestures";
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
@@ -67,8 +71,10 @@ interface DomEditOverlayProps {
gridSpacing?: number;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
}
// fallow-ignore-next-line complexity
export const DomEditOverlay = memo(function DomEditOverlay({
iframeRef,
activeCompositionPath,
@@ -88,13 +94,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
onMarqueeSelect,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const onMarqueeSelectRef = useRef(onMarqueeSelect);
onMarqueeSelectRef.current = onMarqueeSelect;
const selectionShapeStyles = (() => {
const fallback = {
borderRadius: 4 as string | number,
borderRadius: 8 as string | number,
clipPath: undefined as string | undefined,
};
if (!selection?.element) return fallback;
@@ -213,6 +222,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
return () => cancelAnimationFrame(frame);
});
// Off-canvas element indicators — dashed outlines for elements positioned
// outside the composition bounds so users can find them.
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
useEffect(() => {
const iframe = iframeRef.current;
const overlay = overlayRef.current;
if (!iframe || !overlay || compRect.width <= 0) {
setOffCanvasRects([]);
return;
}
const doc = iframe.contentDocument;
if (!doc) return;
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
const acp = activeCompositionPath ?? "index.html";
const items = collectDomEditLayerItems(root, {
activeCompositionPath: acp,
isMasterView: !acp || acp === "index.html",
});
const rects: typeof offCanvasRects = [];
const elMap = new Map<string, HTMLElement>();
for (const item of items) {
if (!isElementComputedVisible(item.element)) continue;
const r = toOverlayRect(overlay, iframe, item.element);
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
const extendsOutsideComp =
r.left < compRect.left ||
r.left + r.width > compRect.left + compRect.width ||
r.top < compRect.top ||
r.top + r.height > compRect.top + compRect.height;
if (extendsOutsideComp) {
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
elMap.set(item.key, item.element);
}
}
offCanvasElementsRef.current = elMap;
setOffCanvasRects(rects);
// Positions depend on layout, not selection — the selected-element
// suppression is a render-time filter, so selection/groupSelections stay
// out of the deps to avoid re-walking geometry on each selection change.
}, [iframeRef, compRect, activeCompositionPath]);
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
iframeRef,
@@ -238,6 +291,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
snapGuidesRef,
});
const marquee = useMarqueeGestures({
iframeRef,
overlayRef,
activeCompositionPathRef,
onMarqueeSelectRef,
selectionRef,
gestures,
});
const selectionKey = useMemo(() => {
if (!selection) return "none";
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
@@ -265,22 +327,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
// Don't re-resolve selection when clicking outside the composition bounds —
// the iframe can't resolve elements there, so it would clear the selection.
if (selection && compRect.width > 0) {
const overlayEl = overlayRef.current;
if (overlayEl) {
const overlayRect = overlayEl.getBoundingClientRect();
const clickX = event.clientX - overlayRect.left;
const clickY = event.clientY - overlayRect.top;
const outsideComp =
clickX < compRect.left ||
clickX > compRect.left + compRect.width ||
clickY < compRect.top ||
clickY > compRect.top + compRect.height;
if (outsideComp) 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.
onCanvasMouseDown(event, { preferClipAncestor: false });
if (event.shiftKey) {
suppressNextBoxMouseDownRef.current = true;
@@ -306,6 +355,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
// Start marquee if clicking on empty canvas (no element under pointer)
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
const overlayEl = overlayRef.current;
if (overlayEl) {
const oRect = overlayEl.getBoundingClientRect();
const cx = event.clientX - oRect.left;
const cy = event.clientY - oRect.top;
const inComp =
cx >= compRect.left &&
cx <= compRect.left + compRect.width &&
cy >= compRect.top &&
cy <= compRect.top + compRect.height;
if (inComp) {
event.preventDefault();
event.stopPropagation();
suppressNextOverlayMouseDownRef.current = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
marquee.marqueeRef.current = {
startX: cx,
startY: cy,
currentX: cx,
currentY: cy,
pointerId: event.pointerId,
pastThreshold: false,
};
return;
}
}
}
};
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -332,44 +411,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
className="absolute inset-0 z-10 pointer-events-auto outline-none"
tabIndex={-1}
aria-label="Composition canvas"
// Cursor follows marquee rect *state* (re-renders), not the mutable ref.
style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
onPointerDownCapture={(event) =>
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
}
onPointerDown={handleOverlayPointerDown}
onMouseDown={handleOverlayMouseDown}
onPointerMove={gestures.onPointerMove}
onPointerMove={marquee.onPointerMove}
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
onPointerUp={gestures.onPointerUp}
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
onPointerUp={marquee.onPointerUp}
onPointerCancel={marquee.onPointerCancel}
>
{hoverSelection && hoverRect && compRect.width > 0 && (
<div
aria-hidden="true"
data-dom-edit-hover-box="true"
className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={(() => {
let br: string | number = 4;
let cp: string | undefined;
try {
const el = hoverSelection.element;
const tag = el.tagName.toLowerCase();
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
}
} catch {
/* cross-origin guard */
}
return {
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
borderRadius: br,
clipPath: cp,
};
})()}
className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{
left: hoverRect.left,
top: hoverRect.top,
width: hoverRect.width,
height: hoverRect.height,
}}
/>
)}
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -437,13 +501,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
key={selectionKey}
ref={boxRef}
data-dom-edit-selection-box="true"
className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width,
height: overlayRect.height,
borderRadius: selectionShapeStyles.borderRadius,
clipPath: selectionShapeStyles.clipPath,
cursor:
allowCanvasMovement && selection.capabilities.canApplyManualOffset
@@ -496,6 +559,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
}}
/>
))}
<OffCanvasIndicators
rects={offCanvasRects}
elements={offCanvasElementsRef}
compRect={compRect}
selection={selection}
groupSelections={groupSelections}
activeCompositionPathRef={activeCompositionPathRef}
onSelectionChangeRef={onSelectionChangeRef}
/>
{marquee.marqueeRect && (
<div
aria-hidden="true"
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
style={{
left: marquee.marqueeRect.left,
top: marquee.marqueeRect.top,
width: marquee.marqueeRect.width,
height: marquee.marqueeRect.height,
}}
/>
)}
<GridOverlay
visible={gridVisible}
spacing={gridSpacing}
@@ -0,0 +1,111 @@
import React from "react";
import { type DomEditSelection } from "./domEditing";
export interface OffCanvasRect {
key: string;
left: number;
top: number;
width: number;
height: number;
}
interface OffCanvasIndicatorsProps {
rects: OffCanvasRect[];
elements: React.MutableRefObject<Map<string, HTMLElement>>;
compRect: { left: number; top: number; width: number; height: number };
selection: DomEditSelection | null;
groupSelections: DomEditSelection[];
activeCompositionPathRef: React.MutableRefObject<string | null>;
onSelectionChangeRef: React.MutableRefObject<
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
>;
}
/**
* Dashed teal indicators for elements whose bounds extend past the composition
* (the "gray zone"). The in-canvas portion is clipped away so only the
* protruding sliver is dashed; the inside portion gets a solid outline.
* Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
*/
export function OffCanvasIndicators({
rects,
elements,
compRect,
selection,
groupSelections,
activeCompositionPathRef,
onSelectionChangeRef,
}: OffCanvasIndicatorsProps): React.ReactElement {
return (
<>
{rects
.filter((r) => {
// Suppress the indicator for any currently-selected element (primary
// OR a marquee group member) — those already render a selection box.
const el = elements.current.get(r.key);
if (!el) return true;
if (selection?.element === el) return false;
return !groupSelections.some((g) => g.element === el);
})
.map((r) => {
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
const cL = Math.max(0, compRect.left - r.left);
const cT = Math.max(0, compRect.top - r.top);
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
const hasInside = cL < cR && cT < cB;
const clipOutside = hasInside
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
: undefined;
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
const selectOffCanvas = async () => {
const el = elements.current.get(r.key);
if (!el) return;
const { resolveDomEditSelection } = await import("./domEditingLayers");
const acp = activeCompositionPathRef.current ?? "index.html";
const sel = await resolveDomEditSelection(el, {
activeCompositionPath: acp,
isMasterView: !acp || acp === "index.html",
skipSourceProbe: true,
});
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
void selectOffCanvas();
};
return (
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
{/* Dashed layer — clipped to exclude canvas area.
Note: clip-path is visual only — hit-testing still covers the
full bounding rect, so clicking the in-canvas portion selects
via this handler. That's acceptable: it resolves the same
element the normal canvas path would, just with
skipSourceProbe (the element is already known here). */}
<div
role="button"
tabIndex={0}
aria-label={`Select off-canvas element ${r.key}`}
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
style={clipOutside ? { clipPath: clipOutside } : undefined}
title={`Off-canvas: ${r.key} — click to select`}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
void selectOffCanvas();
}
}}
/>
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
<div
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
style={{ clipPath: clipInside }}
/>
</div>
);
})}
</>
);
}
@@ -0,0 +1,168 @@
import { useCallback, useRef, useState } from "react";
import type { DomEditSelection } from "./domEditing";
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import { coversComposition } from "../../utils/studioPreviewHelpers";
import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
interface MarqueeState {
startX: number;
startY: number;
currentX: number;
currentY: number;
pointerId: number;
pastThreshold: boolean;
}
const MARQUEE_THRESHOLD_PX = 4;
// fallow-ignore-next-line complexity
async function runMarqueeIntersection(
rect: { left: number; top: number; width: number; height: number },
iframe: HTMLIFrameElement,
overlayEl: HTMLDivElement,
activeCompositionPath: string,
): Promise<DomEditSelection[]> {
const doc = iframe.contentDocument;
if (!doc) return [];
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
const viewport = {
width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
};
const hits: DomEditSelection[] = [];
for (const item of items) {
const el = item.element;
if (!isElementComputedVisible(el)) continue;
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
const corners = elementObbCorners(el, overlayEl, iframe);
if (!corners) continue;
if (!marqueeIntersectsObb(rect, corners)) continue;
const sel = await resolveDomEditSelection(el, {
activeCompositionPath,
isMasterView,
skipSourceProbe: true,
});
if (sel) hits.push(sel);
}
return hits;
}
interface MarqueeGesturesDeps {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
overlayRef: React.RefObject<HTMLDivElement | null>;
activeCompositionPathRef: React.RefObject<string | null>;
onMarqueeSelectRef: React.RefObject<
((selections: DomEditSelection[], additive: boolean) => void) | undefined
>;
selectionRef: React.RefObject<DomEditSelection | null>;
gestures: {
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
};
}
// fallow-ignore-next-line complexity
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
const marqueeRef = useRef<MarqueeState | null>(null);
const [marqueeRect, setMarqueeRect] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
const commitMarquee = useCallback(
async (
rect: { left: number; top: number; width: number; height: number },
additive: boolean,
) => {
const iframe = deps.iframeRef.current;
const overlay = deps.overlayRef.current;
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
const acp = deps.activeCompositionPathRef.current ?? "index.html";
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
deps.onMarqueeSelectRef.current(hits, additive);
},
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const m = marqueeRef.current;
if (m) {
const oRect = deps.overlayRef.current?.getBoundingClientRect();
if (!oRect) return;
m.currentX = event.clientX - oRect.left;
m.currentY = event.clientY - oRect.top;
if (!m.pastThreshold) {
const dx = m.currentX - m.startX;
const dy = m.currentY - m.startY;
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
m.pastThreshold = true;
}
setMarqueeRect({
left: Math.min(m.startX, m.currentX),
top: Math.min(m.startY, m.currentY),
width: Math.abs(m.currentX - m.startX),
height: Math.abs(m.currentY - m.startY),
});
return;
}
deps.gestures.onPointerMove(event);
},
[deps.gestures, deps.overlayRef],
);
const onPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const m = marqueeRef.current;
if (m) {
marqueeRef.current = null;
try {
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
} catch {
/* already released */
}
if (m.pastThreshold) {
commitMarquee(
{
left: Math.min(m.startX, m.currentX),
top: Math.min(m.startY, m.currentY),
width: Math.abs(m.currentX - m.startX),
height: Math.abs(m.currentY - m.startY),
},
event.shiftKey,
);
} else {
deps.onMarqueeSelectRef.current?.([], false);
}
setMarqueeRect(null);
return;
}
deps.gestures.onPointerUp(event);
},
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
);
const onPointerCancel = useCallback(() => {
if (marqueeRef.current) {
marqueeRef.current = null;
setMarqueeRect(null);
return;
}
deps.gestures.clearPointerState(deps.selectionRef);
}, [deps.gestures, deps.selectionRef]);
return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
}
@@ -66,6 +66,7 @@ export interface PropertyPanelProps {
value: number | string,
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
@@ -61,6 +61,8 @@ export interface DomEditActionsValue extends Pick<
| "invalidateGsapCache"
| "previewIframeRef"
| "commitMutation"
| "applyMarqueeSelection"
| "handleUpdateKeyframeEase"
> {}
export interface DomEditSelectionValue extends Pick<
@@ -167,6 +169,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
commitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
},
children,
}: {
@@ -238,6 +242,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
commitMutation: stableCommitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
}),
[
handleTimelineElementSelect,
@@ -295,6 +301,8 @@ export function DomEditProvider({
invalidateGsapCache,
previewIframeRef,
stableCommitMutation,
applyMarqueeSelection,
handleUpdateKeyframeEase,
],
);
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
if (!nextElement) {
// The selected element no longer resolves in the (re-synced) document
// — comp/hot reload, activeCompPath swap, or post-save replacement.
// Clear so overlay geometry isn't computed on a stale, detached node.
// (Drag-release-in-gray-zone is handled separately by
// suppressNextBoxClickRef; the dragged element still resolves here.)
applyDomSelection(null, { revealPanel: false });
return;
}
@@ -1,3 +1,4 @@
import { useCallback } from "react";
import type { TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
@@ -123,6 +124,7 @@ export function useDomEditSession({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
applyMarqueeSelection,
} = useDomSelection({
projectId,
activeCompPath,
@@ -389,6 +391,25 @@ export function useDomEditSession({
updateArcSegment,
});
const handleUpdateKeyframeEase = useCallback(
(animationId: string, percentage: number, ease: string) => {
const sel = domEditSelectionRef.current;
if (!sel) return;
gsapCommitMutation(
sel,
{
type: "update-keyframe",
animationId,
percentage,
properties: {},
ease,
},
{ label: "Update keyframe ease", softReload: true },
);
},
[gsapCommitMutation, domEditSelectionRef],
);
return {
// State
domEditSelection,
@@ -429,6 +450,7 @@ export function useDomEditSession({
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
applyMarqueeSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
@@ -454,6 +476,7 @@ export function useDomEditSession({
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
handleUpdateKeyframeEase,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
@@ -85,6 +85,7 @@ export interface UseDomSelectionReturn {
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
// ── Hook ──
@@ -419,6 +420,50 @@ export function useDomSelection({
applyDomSelection(null, { revealPanel: false });
}, [applyDomSelection, captionEditMode]);
const applyMarqueeSelection = useCallback(
// fallow-ignore-next-line complexity
(selections: DomEditSelection[], additive: boolean) => {
// Honor the inspector-panels kill switch like applyDomSelection does, so
// marquee can't land selections while the inspector UI is suppressed.
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
domEditSelectionRef.current = null;
domEditGroupSelectionsRef.current = [];
setDomEditSelection(null);
setDomEditGroupSelections([]);
return;
}
if (selections.length === 0) {
if (!additive) applyDomSelection(null, { revealPanel: false });
return;
}
const current = domEditSelectionRef.current;
const currentGroup = domEditGroupSelectionsRef.current;
let nextGroup: DomEditSelection[];
if (additive) {
nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
for (const s of selections) {
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
}
} else {
nextGroup = selections;
}
const nextSelection = additive && current ? current : selections[0];
domEditSelectionRef.current = nextSelection;
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
const nextTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
setSelectedTimelineElementId(nextTimelineId);
},
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
);
// Disabled inspector effect
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -451,5 +496,6 @@ export function useDomSelection({
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
};
}
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { marqueeIntersectsObb, rectsOverlap, type Point, type Rect } from "./marqueeGeometry";
type Corners = [Point, Point, Point, Point];
function rotateCorners(cx: number, cy: number, w: number, h: number, deg: number): Corners {
const rad = (deg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const hw = w / 2;
const hh = h / 2;
const local: [number, number][] = [
[-hw, -hh],
[hw, -hh],
[hw, hh],
[-hw, hh],
];
return local.map(([lx, ly]) => ({
x: cx + cos * lx - sin * ly,
y: cy + sin * lx + cos * ly,
})) as Corners;
}
function aabbCorners(r: Rect): Corners {
return [
{ x: r.left, y: r.top },
{ x: r.left + r.width, y: r.top },
{ x: r.left + r.width, y: r.top + r.height },
{ x: r.left, y: r.top + r.height },
];
}
describe("rectsOverlap", () => {
it("overlapping rects", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 10, height: 10 },
{ left: 5, top: 5, width: 10, height: 10 },
),
).toBe(true);
});
it("non-overlapping rects", () => {
expect(
rectsOverlap(
{ left: 0, top: 0, width: 10, height: 10 },
{ left: 20, top: 20, width: 10, height: 10 },
),
).toBe(false);
});
});
describe("marqueeIntersectsObb", () => {
it("axis-aligned overlap", () => {
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
const corners = aabbCorners({ left: 50, top: 50, width: 80, height: 80 });
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("axis-aligned no overlap", () => {
const marquee: Rect = { left: 0, top: 0, width: 50, height: 50 };
const corners = aabbCorners({ left: 100, top: 100, width: 50, height: 50 });
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
});
it("marquee fully contains element", () => {
const marquee: Rect = { left: 0, top: 0, width: 200, height: 200 };
const corners = aabbCorners({ left: 50, top: 50, width: 20, height: 20 });
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("element fully contains marquee", () => {
const marquee: Rect = { left: 50, top: 50, width: 10, height: 10 };
const corners = aabbCorners({ left: 0, top: 0, width: 200, height: 200 });
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("45-degree rotated square: AABB overlaps but OBB does not", () => {
// 100x100 square rotated 45° centered at (200,200)
// Its AABB extends to ~(129,129)-(271,271)
// A marquee at (0,0)-(135,135) overlaps the AABB but NOT the diamond
const corners = rotateCorners(200, 200, 100, 100, 45);
const marquee: Rect = { left: 0, top: 0, width: 135, height: 135 };
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
});
it("45-degree rotated square: OBB overlaps", () => {
// Same rotated square, marquee reaches the diamond's left point
const corners = rotateCorners(200, 200, 100, 100, 45);
const marquee: Rect = { left: 0, top: 150, width: 155, height: 100 };
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("zero-width marquee returns false", () => {
const corners = aabbCorners({ left: 0, top: 0, width: 100, height: 100 });
expect(marqueeIntersectsObb({ left: 50, top: 50, width: 0, height: 50 }, corners)).toBe(false);
});
it("zero-area element returns false for degenerate OBB", () => {
const corners: Corners = [
{ x: 50, y: 50 },
{ x: 50, y: 50 },
{ x: 50, y: 50 },
{ x: 50, y: 50 },
];
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
// Degenerate point — SAT still works (projections are zero-length intervals)
// A point inside the marquee should still intersect
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("30-degree rotated rectangle clips marquee corner", () => {
const corners = rotateCorners(150, 150, 200, 50, 30);
const marquee: Rect = { left: 0, top: 0, width: 80, height: 130 };
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
});
it("30-degree rotated rectangle misses marquee", () => {
const corners = rotateCorners(300, 300, 50, 50, 30);
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
});
});
@@ -0,0 +1,172 @@
export interface Point {
x: number;
y: number;
}
export interface Rect {
left: number;
top: number;
width: number;
height: number;
}
type Corners = [Point, Point, Point, Point];
function isIdentityMatrix(m: DOMMatrix): boolean {
const e = 1e-6;
return Math.abs(m.a - 1) < e && Math.abs(m.b) < e && Math.abs(m.c) < e && Math.abs(m.d - 1) < e;
}
function rectsOverlap(a: Rect, b: Rect): boolean {
return (
a.left < b.left + b.width &&
a.left + a.width > b.left &&
a.top < b.top + b.height &&
a.top + a.height > b.top
);
}
function projectOntoAxis(corners: Corners, ax: number, ay: number): [number, number] {
let min = Infinity;
let max = -Infinity;
for (const c of corners) {
const dot = c.x * ax + c.y * ay;
if (dot < min) min = dot;
if (dot > max) max = dot;
}
return [min, max];
}
function projectionsOverlap(a: [number, number], b: [number, number]): boolean {
return a[0] <= b[1] && b[0] <= a[1];
}
/**
* SAT intersection test between an axis-aligned marquee rect and a
* convex quadrilateral (the element's OBB corners in overlay space).
*
* Separating axes: 2 from the AABB (horizontal, vertical) + 2 from
* the OBB's edge normals. If projections overlap on all 4 axes, the
* shapes intersect.
*/
export function marqueeIntersectsObb(marquee: Rect, corners: Corners): boolean {
if (marquee.width <= 0 || marquee.height <= 0) return false;
const mCorners: Corners = [
{ x: marquee.left, y: marquee.top },
{ x: marquee.left + marquee.width, y: marquee.top },
{ x: marquee.left + marquee.width, y: marquee.top + marquee.height },
{ x: marquee.left, y: marquee.top + marquee.height },
];
// AABB axes: (1,0) and (0,1)
const mProjX: [number, number] = [marquee.left, marquee.left + marquee.width];
const mProjY: [number, number] = [marquee.top, marquee.top + marquee.height];
const oProjX = projectOntoAxis(corners, 1, 0);
const oProjY = projectOntoAxis(corners, 0, 1);
if (!projectionsOverlap(mProjX, oProjX)) return false;
if (!projectionsOverlap(mProjY, oProjY)) return false;
// OBB edge normals (only need 2 — edges 0→1 and 1→2)
for (let i = 0; i < 2; i++) {
const edge = {
x: corners[i + 1].x - corners[i].x,
y: corners[i + 1].y - corners[i].y,
};
const len = Math.hypot(edge.x, edge.y);
if (len < 1e-9) continue;
const ax = -edge.y / len;
const ay = edge.x / len;
const mProj = projectOntoAxis(mCorners, ax, ay);
const oProj = projectOntoAxis(corners, ax, ay);
if (!projectionsOverlap(mProj, oProj)) return false;
}
return true;
}
/**
* Compute the four corners of an element's OBB in overlay-pixel space.
*
* For elements with an identity transform, returns the axis-aligned
* corners from the element's BCR mapped to overlay space (fast path).
*/
// fallow-ignore-next-line complexity
export function elementObbCorners(
element: HTMLElement,
overlayEl: HTMLDivElement,
iframe: HTMLIFrameElement,
): Corners | null {
const doc = iframe.contentDocument;
if (!doc) return null;
const iframeRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
const declaredW = Number.parseFloat(root?.getAttribute("data-width") ?? "");
const declaredH = Number.parseFloat(root?.getAttribute("data-height") ?? "");
const rootW = declaredW > 0 ? declaredW : root?.getBoundingClientRect().width || 1;
const rootH = declaredH > 0 ? declaredH : root?.getBoundingClientRect().height || 1;
const scaleX = iframeRect.width / rootW;
const scaleY = iframeRect.height / rootH;
const offsetX = iframeRect.left - overlayRect.left;
const offsetY = iframeRect.top - overlayRect.top;
const win = element.ownerDocument.defaultView;
if (!win) return null;
const transform = win.getComputedStyle(element).transform;
const m = transform && transform !== "none" ? new DOMMatrix(transform) : new DOMMatrix();
if (isIdentityMatrix(m)) {
const r = element.getBoundingClientRect();
const left = offsetX + r.left * scaleX;
const top = offsetY + r.top * scaleY;
const w = r.width * scaleX;
const h = r.height * scaleY;
return [
{ x: left, y: top },
{ x: left + w, y: top },
{ x: left + w, y: top + h },
{ x: left, y: top + h },
];
}
// Walk offsetParent chain for pre-transform position
let ox = 0;
let oy = 0;
let el: HTMLElement | null = element;
while (el && el !== doc.body && el !== doc.documentElement) {
ox += el.offsetLeft;
oy += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
const w = element.offsetWidth;
const h = element.offsetHeight;
// ponytail: center-based transform — CSS transforms originate at 50% 50%
const cx = ox + w / 2;
const cy = oy + h / 2;
const localCorners: [number, number][] = [
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
];
return localCorners.map(([lx, ly]) => {
const tx = m.a * lx + m.c * ly + cx;
const ty = m.b * lx + m.d * ly + cy;
return {
x: offsetX + tx * scaleX,
y: offsetY + ty * scaleY,
};
}) as Corners;
}
export { rectsOverlap };