Skip to content

Commit 29c439e

Browse files
committed
feat(studio): off-screen indicators + unclipped overlay
Add useOffScreenIndicators hook (RAF-driven detection of GSAP elements outside composition bounds) with draggable indicators, click-to-select, and drag-to-move. Split NLE layout: inner overflow-hidden clips iframe, outer container leaves overlay unclipped for handle interaction.
1 parent da4a238 commit 29c439e

4 files changed

Lines changed: 300 additions & 14 deletions

File tree

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function StudioPreviewArea({
108108
handlePreviewCanvasPointerMove,
109109
handlePreviewCanvasPointerLeave,
110110
applyDomSelection,
111+
buildDomSelectionFromTarget,
111112
handleBlockedDomMove,
112113
handleDomManualDragStart,
113114
handleDomPathOffsetCommit,
@@ -279,6 +280,14 @@ export function StudioPreviewArea({
279280
onRotationCommit={handleDomRotationCommit}
280281
gridVisible={snapPrefs.gridVisible}
281282
gridSpacing={snapPrefs.gridSpacing}
283+
onSelectElementById={async (id) => {
284+
const iframe = previewIframeRef.current;
285+
const el = iframe?.contentDocument?.getElementById(id);
286+
if (!el) return null;
287+
const sel = await buildDomSelectionFromTarget(el);
288+
if (sel) applyDomSelection(sel, { revealPanel: true });
289+
return sel;
290+
}}
282291
/>
283292
<SnapToolbar onSnapChange={setSnapPrefs} />
284293
{gestureOverlay}

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
1313
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
1414
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
1515
import { GridOverlay } from "./GridOverlay";
16+
import { useOffScreenIndicators } from "./useOffScreenIndicators";
1617

1718
// Re-exports for external consumers — preserving existing import paths.
1819
export {
@@ -54,6 +55,7 @@ interface DomEditOverlayProps {
5455
) => void;
5556
onBlockedMove: (selection: DomEditSelection) => void;
5657
onManualDragStart?: () => void;
58+
onSelectElementById?: (id: string) => Promise<DomEditSelection | null>;
5759
onPathOffsetCommit: (
5860
selection: DomEditSelection,
5961
next: { x: number; y: number },
@@ -83,6 +85,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8385
gridVisible = false,
8486
gridSpacing = 50,
8587
onManualDragStart,
88+
onSelectElementById,
8689
onPathOffsetCommit,
8790
onGroupPathOffsetCommit,
8891
onBoxSizeCommit,
@@ -212,6 +215,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
212215
return () => cancelAnimationFrame(frame);
213216
});
214217

218+
const offScreenIndicators = useOffScreenIndicators({ iframeRef, overlayRef, compRect });
219+
215220
const gestures = createDomEditOverlayGestureHandlers({
216221
overlayRef,
217222
iframeRef,
@@ -263,6 +268,22 @@ export const DomEditOverlay = memo(function DomEditOverlay({
263268
}
264269
const target = event.target as HTMLElement | null;
265270
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
271+
// Don't re-resolve selection when clicking outside the composition bounds —
272+
// the iframe can't resolve elements there, so it would clear the selection.
273+
if (selection && compRect.width > 0) {
274+
const overlayEl = overlayRef.current;
275+
if (overlayEl) {
276+
const overlayRect = overlayEl.getBoundingClientRect();
277+
const clickX = event.clientX - overlayRect.left;
278+
const clickY = event.clientY - overlayRect.top;
279+
const outsideComp =
280+
clickX < compRect.left ||
281+
clickX > compRect.left + compRect.width ||
282+
clickY < compRect.top ||
283+
clickY > compRect.top + compRect.height;
284+
if (outsideComp) return;
285+
}
286+
}
266287
onCanvasMouseDown(event, { preferClipAncestor: false });
267288
if (event.shiftKey) {
268289
suppressNextBoxMouseDownRef.current = true;
@@ -500,6 +521,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({
500521
}}
501522
/>
502523
))}
524+
{offScreenIndicators.length > 0 &&
525+
compRect.width > 0 &&
526+
offScreenIndicators.map((ind) => {
527+
const isSelected = selection?.id === ind.elementId;
528+
return (
529+
<div
530+
key={`offscreen-${ind.key}`}
531+
className={`absolute rounded-sm ${isSelected ? "pointer-events-none" : "cursor-grab"}`}
532+
style={{
533+
left: ind.left,
534+
top: ind.top,
535+
width: ind.width,
536+
height: ind.height,
537+
border: `1.5px dashed var(--panel-accent, #34d399)`,
538+
opacity: isSelected ? 0.3 : 0.5,
539+
zIndex: isSelected ? 1 : 5,
540+
}}
541+
onPointerDown={
542+
isSelected
543+
? undefined
544+
: (e) => {
545+
if (e.button !== 0) return;
546+
e.stopPropagation();
547+
e.preventDefault();
548+
const startX = e.clientX;
549+
const startY = e.clientY;
550+
const el = e.currentTarget;
551+
el.setPointerCapture(e.pointerId);
552+
let deltaX = 0;
553+
let deltaY = 0;
554+
let moved = false;
555+
const onMove = (me: PointerEvent) => {
556+
deltaX = me.clientX - startX;
557+
deltaY = me.clientY - startY;
558+
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) moved = true;
559+
if (moved) {
560+
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
561+
}
562+
};
563+
const onUp = async (ue: PointerEvent) => {
564+
el.releasePointerCapture(ue.pointerId);
565+
el.removeEventListener("pointermove", onMove);
566+
el.removeEventListener("pointerup", onUp);
567+
el.style.transform = "";
568+
const sel = await onSelectElementById?.(ind.elementId);
569+
if (moved && sel && onPathOffsetCommit) {
570+
const scale = compRect.scaleX || 1;
571+
onPathOffsetCommit(sel, { x: deltaX / scale, y: deltaY / scale });
572+
}
573+
};
574+
el.addEventListener("pointermove", onMove);
575+
el.addEventListener("pointerup", onUp);
576+
}
577+
}
578+
title={isSelected ? undefined : `Drag #${ind.elementId}`}
579+
/>
580+
);
581+
})}
503582
<GridOverlay
504583
visible={gridVisible}
505584
spacing={gridSpacing}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Detects GSAP-animated elements whose center is outside the visible composition
3+
* area and returns edge-clamped indicator positions for each.
4+
*/
5+
import { useRef, useState, type RefObject } from "react";
6+
import { useMountEffect } from "../../hooks/useMountEffect";
7+
8+
export interface OffScreenIndicator {
9+
key: string;
10+
elementId: string;
11+
left: number;
12+
top: number;
13+
width: number;
14+
height: number;
15+
}
16+
17+
interface CompRect {
18+
left: number;
19+
top: number;
20+
width: number;
21+
height: number;
22+
scaleX: number;
23+
scaleY: number;
24+
}
25+
26+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
27+
28+
function isHtmlElement(node: unknown): node is HTMLElement {
29+
return (
30+
typeof node === "object" &&
31+
node !== null &&
32+
typeof (node as HTMLElement).getBoundingClientRect === "function" &&
33+
typeof (node as HTMLElement).tagName === "string"
34+
);
35+
}
36+
37+
function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
38+
const win = iframe.contentWindow as
39+
| (Window & { __timelines?: Record<string, TimelineLike> })
40+
| null;
41+
if (!win) return [];
42+
43+
let timelines: Record<string, TimelineLike> | undefined;
44+
try {
45+
timelines = win.__timelines;
46+
} catch {
47+
return [];
48+
}
49+
if (!timelines) return [];
50+
51+
const seen = new Set<HTMLElement>();
52+
for (const tl of Object.values(timelines)) {
53+
if (!tl?.getChildren) continue;
54+
try {
55+
for (const child of tl.getChildren(true)) {
56+
if (!child.targets) continue;
57+
for (const t of child.targets()) {
58+
if (isHtmlElement(t)) seen.add(t);
59+
}
60+
}
61+
} catch {
62+
// cross-origin or detached timeline — skip
63+
}
64+
}
65+
return Array.from(seen);
66+
}
67+
68+
function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
69+
if (a.length !== b.length) return false;
70+
for (let i = 0; i < a.length; i++) {
71+
const ai = a[i]!;
72+
const bi = b[i]!;
73+
if (
74+
ai.key !== bi.key ||
75+
Math.abs(ai.left - bi.left) > 0.5 ||
76+
Math.abs(ai.top - bi.top) > 0.5 ||
77+
Math.abs(ai.width - bi.width) > 0.5 ||
78+
Math.abs(ai.height - bi.height) > 0.5
79+
)
80+
return false;
81+
}
82+
return true;
83+
}
84+
85+
export function useOffScreenIndicators({
86+
iframeRef,
87+
overlayRef,
88+
compRect,
89+
}: {
90+
iframeRef: RefObject<HTMLIFrameElement | null>;
91+
overlayRef: RefObject<HTMLDivElement | null>;
92+
compRect: CompRect;
93+
}): OffScreenIndicator[] {
94+
const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
95+
const prevRef = useRef<OffScreenIndicator[]>([]);
96+
const compRectRef = useRef(compRect);
97+
compRectRef.current = compRect;
98+
99+
useMountEffect(() => {
100+
let frame = 0;
101+
102+
const update = () => {
103+
frame = requestAnimationFrame(update);
104+
105+
const iframe = iframeRef.current;
106+
const overlayEl = overlayRef.current;
107+
const cr = compRectRef.current;
108+
if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
109+
if (prevRef.current.length > 0) {
110+
prevRef.current = [];
111+
setIndicators([]);
112+
}
113+
return;
114+
}
115+
116+
const iframeRect = iframe.getBoundingClientRect();
117+
const overlayRect = overlayEl.getBoundingClientRect();
118+
119+
const doc = iframe.contentDocument;
120+
const root =
121+
doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
122+
if (!root) return;
123+
124+
const declaredWidth =
125+
Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
126+
const declaredHeight =
127+
Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
128+
const rootScaleX = iframeRect.width / declaredWidth;
129+
const rootScaleY = iframeRect.height / declaredHeight;
130+
131+
const targets = collectGsapTargetElements(iframe);
132+
if (targets.length === 0) {
133+
if (prevRef.current.length > 0) {
134+
prevRef.current = [];
135+
setIndicators([]);
136+
}
137+
return;
138+
}
139+
140+
// Composition bounds in overlay coordinates
141+
const compLeft = cr.left;
142+
const compTop = cr.top;
143+
const compRight = compLeft + cr.width;
144+
const compBottom = compTop + cr.height;
145+
146+
const next: OffScreenIndicator[] = [];
147+
const keyCounts = new Map<string, number>();
148+
149+
for (const el of targets) {
150+
if (!el.isConnected) continue;
151+
152+
const elRect = el.getBoundingClientRect();
153+
if (elRect.width <= 0 && elRect.height <= 0) continue;
154+
155+
// Element rect in overlay coordinates
156+
const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
157+
const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
158+
const elW = elRect.width * rootScaleX;
159+
const elH = elRect.height * rootScaleY;
160+
161+
// Check if the element is fully inside the composition
162+
if (
163+
elLeft >= compLeft &&
164+
elTop >= compTop &&
165+
elLeft + elW <= compRight &&
166+
elTop + elH <= compBottom
167+
) {
168+
continue;
169+
}
170+
171+
const base = el.id || el.getAttribute("data-hf-id") || el.tagName.toLowerCase();
172+
const count = keyCounts.get(base) ?? 0;
173+
keyCounts.set(base, count + 1);
174+
const key = count > 0 ? `${base}:${count}` : base;
175+
next.push({
176+
key,
177+
elementId: el.id || base,
178+
left: elLeft,
179+
top: elTop,
180+
width: elW,
181+
height: elH,
182+
});
183+
}
184+
185+
if (!indicatorsEqual(prevRef.current, next)) {
186+
prevRef.current = next;
187+
setIndicators(next);
188+
}
189+
};
190+
191+
frame = requestAnimationFrame(update);
192+
return () => cancelAnimationFrame(frame);
193+
});
194+
195+
return indicators;
196+
}

packages/studio/src/components/nle/NLELayout.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -366,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
366366
{/* Preview + player controls */}
367367
<div className="flex-1 min-h-0 flex flex-col">
368368
<div
369-
className="flex-1 min-h-0 relative overflow-hidden"
369+
className="flex-1 min-h-0 relative"
370370
data-preview-pan-surface="true"
371371
onDragOver={handlePreviewDragOver}
372372
onDragLeave={handlePreviewDragLeave}
373373
onDrop={handlePreviewDrop}
374374
>
375-
<NLEPreview
376-
projectId={projectId}
377-
iframeRef={iframeRef}
378-
onIframeLoad={onIframeLoad}
379-
onCompositionLoadingChange={setCompositionLoading}
380-
portrait={portrait}
381-
directUrl={directUrl}
382-
suppressLoadingOverlay={hasLoadedOnceRef.current}
383-
onStageRef={handleStageRef}
384-
/>
385-
{previewDragOver && (
386-
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
387-
)}
375+
<div className="absolute inset-0 overflow-hidden">
376+
<NLEPreview
377+
projectId={projectId}
378+
iframeRef={iframeRef}
379+
onIframeLoad={onIframeLoad}
380+
onCompositionLoadingChange={setCompositionLoading}
381+
portrait={portrait}
382+
directUrl={directUrl}
383+
suppressLoadingOverlay={hasLoadedOnceRef.current}
384+
onStageRef={handleStageRef}
385+
/>
386+
{previewDragOver && (
387+
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
388+
)}
389+
</div>
388390
{!isFullscreen && previewOverlay}
389391
</div>
390392
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">

0 commit comments

Comments
 (0)