Skip to content

Commit 1f7a259

Browse files
committed
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
1 parent 0054138 commit 1f7a259

10 files changed

Lines changed: 707 additions & 52 deletions

File tree

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
STUDIO_PREVIEW_SELECTION_ENABLED,
1818
} from "./editor/manualEditingAvailability";
1919
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
20-
import { useDomEditContext } from "../contexts/DomEditContext";
20+
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
2121
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
2222
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
2323
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
117117
domEditHoverSelection,
118118
domEditSelection,
119119
domEditGroupSelections,
120+
selectedGsapAnimations,
121+
} = useDomEditSelectionContext();
122+
const {
120123
handleTimelineElementSelect,
121124
handlePreviewCanvasMouseDown,
122125
handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
128131
handleDomGroupPathOffsetCommit,
129132
handleDomBoxSizeCommit,
130133
handleDomRotationCommit,
131-
selectedGsapAnimations,
132134
handleGsapRemoveKeyframe,
133135
handleGsapUpdateMeta,
134136
handleGsapAddKeyframe,
135137
handleGsapConvertToKeyframes,
136138
handleGsapDeleteAllForElement,
137139
buildDomSelectionForTimelineElement,
138-
} = useDomEditContext();
140+
applyMarqueeSelection,
141+
} = useDomEditActionsContext();
139142

143+
// fallow-ignore-next-line complexity
140144
const [snapPrefs, setSnapPrefs] = useState(() => {
141145
const p = readStudioUiPreferences();
142146
return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
160164
const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
161165
handleGsapDeleteAllForElement(`#${rawId}`);
162166
},
167+
// fallow-ignore-next-line complexity
163168
onDeleteKeyframe: (_elId: string, pct: number) => {
164169
const cacheKey = domEditSelection?.id ?? "";
165170
const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
215220
}
216221
}
217222
},
223+
// fallow-ignore-next-line complexity
218224
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
219225
const currentTime = usePlayerStore.getState().currentTime;
220226
const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
339345
gridSpacing={snapPrefs.gridSpacing}
340346
recordingState={recordingState}
341347
onToggleRecording={onToggleRecording}
348+
onMarqueeSelect={applyMarqueeSelection}
342349
/>
343350
<SnapToolbar onSnapChange={setSnapPrefs} />
344351
{STUDIO_KEYFRAMES_ENABLED && (

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function StudioRightPanel({
121121
handleSetArcPath,
122122
handleUpdateArcSegment,
123123
handleUnroll,
124+
handleUpdateKeyframeEase,
124125
handleGsapAddKeyframe,
125126
handleGsapRemoveKeyframe,
126127
handleGsapConvertToKeyframes,
@@ -274,6 +275,7 @@ export function StudioRightPanel({
274275
onSetArcPath={handleSetArcPath}
275276
onUpdateArcSegment={handleUpdateArcSegment}
276277
onUnroll={handleUnroll}
278+
onUpdateKeyframeEase={handleUpdateKeyframeEase}
277279
recordingState={recordingState}
278280
recordingDuration={recordingDuration}
279281
onToggleRecording={onToggleRecording}

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 164 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { memo, useMemo, useRef, useState, type RefObject } from "react";
1+
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
22
import { useMountEffect } from "../../hooks/useMountEffect";
33
import { type DomEditSelection } from "./domEditing";
4-
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
4+
import { useMarqueeGestures } from "./marqueeCommit";
5+
import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
6+
import { collectDomEditLayerItems } from "./domEditingLayers";
7+
import { isElementComputedVisible } from "./domEditingElement";
58
import {
69
type BlockedMoveState,
710
type DomEditGroupPathOffsetCommit,
@@ -67,8 +70,10 @@ interface DomEditOverlayProps {
6770
gridSpacing?: number;
6871
recordingState?: GestureRecordingState;
6972
onToggleRecording?: () => void;
73+
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
7074
}
7175

76+
// fallow-ignore-next-line complexity
7277
export const DomEditOverlay = memo(function DomEditOverlay({
7378
iframeRef,
7479
activeCompositionPath,
@@ -88,13 +93,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8893
onGroupPathOffsetCommit,
8994
onBoxSizeCommit,
9095
onRotationCommit,
96+
onMarqueeSelect,
9197
}: DomEditOverlayProps) {
9298
const overlayRef = useRef<HTMLDivElement | null>(null);
9399
const boxRef = useRef<HTMLDivElement | null>(null);
100+
const onMarqueeSelectRef = useRef(onMarqueeSelect);
101+
onMarqueeSelectRef.current = onMarqueeSelect;
94102

95103
const selectionShapeStyles = (() => {
96104
const fallback = {
97-
borderRadius: 4 as string | number,
105+
borderRadius: 8 as string | number,
98106
clipPath: undefined as string | undefined,
99107
};
100108
if (!selection?.element) return fallback;
@@ -213,6 +221,47 @@ export const DomEditOverlay = memo(function DomEditOverlay({
213221
return () => cancelAnimationFrame(frame);
214222
});
215223

224+
// Off-canvas element indicators — dashed outlines for elements positioned
225+
// outside the composition bounds so users can find them.
226+
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
227+
const [offCanvasRects, setOffCanvasRects] = useState<
228+
{ key: string; left: number; top: number; width: number; height: number }[]
229+
>([]);
230+
useEffect(() => {
231+
const iframe = iframeRef.current;
232+
const overlay = overlayRef.current;
233+
if (!iframe || !overlay || compRect.width <= 0) {
234+
setOffCanvasRects([]);
235+
return;
236+
}
237+
const doc = iframe.contentDocument;
238+
if (!doc) return;
239+
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
240+
const acp = activeCompositionPath ?? "index.html";
241+
const items = collectDomEditLayerItems(root, {
242+
activeCompositionPath: acp,
243+
isMasterView: !acp || acp === "index.html",
244+
});
245+
const rects: typeof offCanvasRects = [];
246+
const elMap = new Map<string, HTMLElement>();
247+
for (const item of items) {
248+
if (!isElementComputedVisible(item.element)) continue;
249+
const r = toOverlayRect(overlay, iframe, item.element);
250+
if (!r) continue;
251+
const partiallyOutside =
252+
r.left < compRect.left ||
253+
r.left + r.width > compRect.left + compRect.width ||
254+
r.top < compRect.top ||
255+
r.top + r.height > compRect.top + compRect.height;
256+
if (partiallyOutside) {
257+
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
258+
elMap.set(item.key, item.element);
259+
}
260+
}
261+
offCanvasElementsRef.current = elMap;
262+
setOffCanvasRects(rects);
263+
}, [iframeRef, compRect, activeCompositionPath, selection]);
264+
216265
const gestures = createDomEditOverlayGestureHandlers({
217266
overlayRef,
218267
iframeRef,
@@ -238,6 +287,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
238287
snapGuidesRef,
239288
});
240289

290+
const marquee = useMarqueeGestures({
291+
iframeRef,
292+
overlayRef,
293+
activeCompositionPathRef,
294+
onMarqueeSelectRef,
295+
selectionRef,
296+
gestures,
297+
});
298+
241299
const selectionKey = useMemo(() => {
242300
if (!selection) return "none";
243301
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
@@ -265,22 +323,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
265323
}
266324
const target = event.target as HTMLElement | null;
267325
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
268-
// Don't re-resolve selection when clicking outside the composition bounds —
269-
// the iframe can't resolve elements there, so it would clear the selection.
270-
if (selection && compRect.width > 0) {
271-
const overlayEl = overlayRef.current;
272-
if (overlayEl) {
273-
const overlayRect = overlayEl.getBoundingClientRect();
274-
const clickX = event.clientX - overlayRect.left;
275-
const clickY = event.clientY - overlayRect.top;
276-
const outsideComp =
277-
clickX < compRect.left ||
278-
clickX > compRect.left + compRect.width ||
279-
clickY < compRect.top ||
280-
clickY > compRect.top + compRect.height;
281-
if (outsideComp) return;
282-
}
283-
}
326+
// Allow clicks anywhere on the overlay — GSAP-translated elements can
327+
// extend beyond the composition rect into the gray zone, and users need
328+
// to select/deselect them by clicking there.
284329
onCanvasMouseDown(event, { preferClipAncestor: false });
285330
if (event.shiftKey) {
286331
suppressNextBoxMouseDownRef.current = true;
@@ -306,6 +351,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
306351

307352
const target = event.target as HTMLElement | null;
308353
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
354+
355+
// Start marquee if clicking on empty canvas (no element under pointer)
356+
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
357+
const overlayEl = overlayRef.current;
358+
if (overlayEl) {
359+
const oRect = overlayEl.getBoundingClientRect();
360+
const cx = event.clientX - oRect.left;
361+
const cy = event.clientY - oRect.top;
362+
const inComp =
363+
cx >= compRect.left &&
364+
cx <= compRect.left + compRect.width &&
365+
cy >= compRect.top &&
366+
cy <= compRect.top + compRect.height;
367+
if (inComp) {
368+
event.preventDefault();
369+
event.stopPropagation();
370+
suppressNextOverlayMouseDownRef.current = true;
371+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
372+
marquee.marqueeRef.current = {
373+
startX: cx,
374+
startY: cy,
375+
currentX: cx,
376+
currentY: cy,
377+
pointerId: event.pointerId,
378+
pastThreshold: false,
379+
};
380+
return;
381+
}
382+
}
383+
}
309384
};
310385

311386
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -332,44 +407,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
332407
className="absolute inset-0 z-10 pointer-events-auto outline-none"
333408
tabIndex={-1}
334409
aria-label="Composition canvas"
410+
style={marquee.marqueeRef.current?.pastThreshold ? { cursor: "crosshair" } : undefined}
335411
onPointerDownCapture={(event) =>
336412
focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
337413
}
338414
onPointerDown={handleOverlayPointerDown}
339415
onMouseDown={handleOverlayMouseDown}
340-
onPointerMove={gestures.onPointerMove}
416+
onPointerMove={marquee.onPointerMove}
341417
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
342-
onPointerUp={gestures.onPointerUp}
343-
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
418+
onPointerUp={marquee.onPointerUp}
419+
onPointerCancel={marquee.onPointerCancel}
344420
>
345421
{hoverSelection && hoverRect && compRect.width > 0 && (
346422
<div
347423
aria-hidden="true"
348424
data-dom-edit-hover-box="true"
349-
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)]"
350-
style={(() => {
351-
let br: string | number = 4;
352-
let cp: string | undefined;
353-
try {
354-
const el = hoverSelection.element;
355-
const tag = el.tagName.toLowerCase();
356-
if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
357-
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
358-
if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
359-
if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
360-
}
361-
} catch {
362-
/* cross-origin guard */
363-
}
364-
return {
365-
left: hoverRect.left,
366-
top: hoverRect.top,
367-
width: hoverRect.width,
368-
height: hoverRect.height,
369-
borderRadius: br,
370-
clipPath: cp,
371-
};
372-
})()}
425+
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)]"
426+
style={{
427+
left: hoverRect.left,
428+
top: hoverRect.top,
429+
width: hoverRect.width,
430+
height: hoverRect.height,
431+
}}
373432
/>
374433
)}
375434
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -437,13 +496,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
437496
key={selectionKey}
438497
ref={boxRef}
439498
data-dom-edit-selection-box="true"
440-
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`}
499+
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`}
441500
style={{
442501
left: overlayRect.left,
443502
top: overlayRect.top,
444503
width: overlayRect.width,
445504
height: overlayRect.height,
446-
borderRadius: selectionShapeStyles.borderRadius,
447505
clipPath: selectionShapeStyles.clipPath,
448506
cursor:
449507
allowCanvasMovement && selection.capabilities.canApplyManualOffset
@@ -496,6 +554,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({
496554
}}
497555
/>
498556
))}
557+
{offCanvasRects
558+
.filter((r) => {
559+
const selEl = selection?.element;
560+
return !selEl || offCanvasElementsRef.current.get(r.key) !== selEl;
561+
})
562+
.map((r) => {
563+
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
564+
const cL = Math.max(0, compRect.left - r.left);
565+
const cT = Math.max(0, compRect.top - r.top);
566+
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
567+
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
568+
const hasInside = cL < cR && cT < cB;
569+
const clipOutside = hasInside
570+
? `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)`
571+
: undefined;
572+
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
573+
const handleClick = async (e: React.MouseEvent) => {
574+
e.stopPropagation();
575+
const el = offCanvasElementsRef.current.get(r.key);
576+
if (!el) return;
577+
const { resolveDomEditSelection } = await import("./domEditingLayers");
578+
const acp = activeCompositionPathRef.current ?? "index.html";
579+
const sel = await resolveDomEditSelection(el, {
580+
activeCompositionPath: acp,
581+
isMasterView: !acp || acp === "index.html",
582+
skipSourceProbe: true,
583+
});
584+
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
585+
};
586+
return (
587+
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
588+
{/* Dashed layer — clipped to exclude canvas area */}
589+
<div
590+
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"
591+
style={clipOutside ? { clipPath: clipOutside } : undefined}
592+
title={`Off-canvas: ${r.key} — click to select`}
593+
onClick={handleClick}
594+
/>
595+
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
596+
<div
597+
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)]"
598+
style={{ clipPath: clipInside }}
599+
/>
600+
</div>
601+
);
602+
})}
603+
{marquee.marqueeRect && (
604+
<div
605+
aria-hidden="true"
606+
className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
607+
style={{
608+
left: marquee.marqueeRect.left,
609+
top: marquee.marqueeRect.top,
610+
width: marquee.marqueeRect.width,
611+
height: marquee.marqueeRect.height,
612+
}}
613+
/>
614+
)}
499615
<GridOverlay
500616
visible={gridVisible}
501617
spacing={gridSpacing}

0 commit comments

Comments
 (0)