@hyperframes/studio 0.7.37 → 0.7.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-BLRTwY5l.js +396 -0
- package/dist/assets/{index-BXYJbIax.js → index-ClUipc8i.js} +1 -1
- package/dist/assets/index-DJaiR8T2.css +1 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-Ykq7ihge.js} +1 -1
- package/dist/index.d.ts +35 -10
- package/dist/index.html +2 -2
- package/dist/index.js +5146 -3321
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +19 -18
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioRightPanel.tsx +154 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/PropertyPanel.tsx +18 -17
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +22 -91
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelTypes.ts +111 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
|
@@ -13,6 +13,7 @@ import type { TimelineElement } from "../../player";
|
|
|
13
13
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
14
14
|
import { NLEPreview } from "./NLEPreview";
|
|
15
15
|
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
16
|
+
import { TimelineResizeDivider, MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
|
|
16
17
|
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
17
18
|
import { useCompositionStack } from "./useCompositionStack";
|
|
18
19
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
getTimelineToggleTitle,
|
|
24
25
|
} from "../../utils/timelineDiscovery";
|
|
25
26
|
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
|
27
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
26
28
|
|
|
27
29
|
interface NLELayoutProps {
|
|
28
30
|
projectId: string;
|
|
@@ -75,9 +77,7 @@ interface NLELayoutProps {
|
|
|
75
77
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
const MIN_TIMELINE_H = 100;
|
|
79
80
|
const DEFAULT_TIMELINE_H = 220;
|
|
80
|
-
const MIN_PREVIEW_H = 120;
|
|
81
81
|
|
|
82
82
|
function subscribeFullscreen(cb: () => void) {
|
|
83
83
|
document.addEventListener("fullscreenchange", cb);
|
|
@@ -138,13 +138,22 @@ export const NLELayout = memo(function NLELayout({
|
|
|
138
138
|
stageRefForDrop.current = ref.current;
|
|
139
139
|
}, []);
|
|
140
140
|
|
|
141
|
+
// Authored composition size measured from the loaded preview — drives drop
|
|
142
|
+
// coordinate mapping so blocks land where the user pointed on any comp size.
|
|
143
|
+
const [previewCompositionSize, setPreviewCompositionSize] = useState<{
|
|
144
|
+
width: number;
|
|
145
|
+
height: number;
|
|
146
|
+
} | null>(null);
|
|
147
|
+
|
|
141
148
|
const {
|
|
142
149
|
isDragOver: previewDragOver,
|
|
150
|
+
handleDragEnter: handlePreviewDragEnter,
|
|
143
151
|
handleDragOver: handlePreviewDragOver,
|
|
144
152
|
handleDragLeave: handlePreviewDragLeave,
|
|
145
153
|
handleDrop: handlePreviewDrop,
|
|
146
154
|
} = usePreviewBlockDrop({
|
|
147
155
|
portrait,
|
|
156
|
+
compositionSize: previewCompositionSize,
|
|
148
157
|
stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
|
|
149
158
|
onBlockDrop: onPreviewBlockDrop,
|
|
150
159
|
});
|
|
@@ -289,7 +298,10 @@ export const NLELayout = memo(function NLELayout({
|
|
|
289
298
|
|
|
290
299
|
useMountEffect(() => {
|
|
291
300
|
fetch(`/api/projects/${projectId}/files/index.html`)
|
|
292
|
-
.then((r) =>
|
|
301
|
+
.then((r) => {
|
|
302
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
303
|
+
return r.json();
|
|
304
|
+
})
|
|
293
305
|
.then((data: { content?: string }) => {
|
|
294
306
|
const html = data.content || "";
|
|
295
307
|
const map = new Map<string, string>();
|
|
@@ -307,7 +319,11 @@ export const NLELayout = memo(function NLELayout({
|
|
|
307
319
|
setCompositionSourceMap(map);
|
|
308
320
|
onCompIdToSrcChange?.(map);
|
|
309
321
|
})
|
|
310
|
-
.catch(() => {
|
|
322
|
+
.catch((err: unknown) => {
|
|
323
|
+
// Non-fatal: drill-down still works via the iframe DOM scan; without
|
|
324
|
+
// the map only source-file resolution for sub-comps degrades.
|
|
325
|
+
console.warn("[studio] Couldn't load composition source map from index.html:", err);
|
|
326
|
+
});
|
|
311
327
|
});
|
|
312
328
|
|
|
313
329
|
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
|
@@ -343,8 +359,24 @@ export const NLELayout = memo(function NLELayout({
|
|
|
343
359
|
});
|
|
344
360
|
}, [compIdToSrc]);
|
|
345
361
|
|
|
346
|
-
// Resizable timeline height
|
|
347
|
-
|
|
362
|
+
// Resizable timeline height — persisted alongside zoom/pan so the user's
|
|
363
|
+
// workspace layout survives reloads.
|
|
364
|
+
const [timelineH, setTimelineH] = useState(() => {
|
|
365
|
+
const stored = readStudioUiPreferences().timelineHeight;
|
|
366
|
+
return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
|
|
367
|
+
});
|
|
368
|
+
const persistTimelineH = useCallback((height: number) => {
|
|
369
|
+
writeStudioUiPreferences({ timelineHeight: Math.round(height) });
|
|
370
|
+
}, []);
|
|
371
|
+
// A height persisted on a tall window can exceed this window's container and
|
|
372
|
+
// collapse the flex-1 preview to 0px — clamp once the container is measurable
|
|
373
|
+
// (the drag/keyboard paths already clamp; the restore path must too).
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
const containerH = containerRef.current?.getBoundingClientRect().height;
|
|
376
|
+
if (!containerH) return;
|
|
377
|
+
const max = containerH - MIN_PREVIEW_H;
|
|
378
|
+
setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
|
|
379
|
+
}, []);
|
|
348
380
|
const hasLoadedOnceRef = useRef(false);
|
|
349
381
|
const [compositionLoading, setCompositionLoadingRaw] = useState(true);
|
|
350
382
|
const setCompositionLoading = useCallback((loading: boolean) => {
|
|
@@ -360,7 +392,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
360
392
|
|
|
361
393
|
const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
|
|
362
394
|
const isTimelineVisible = timelineVisible ?? true;
|
|
363
|
-
const isDragging = useRef(false);
|
|
364
395
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
365
396
|
const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
|
|
366
397
|
|
|
@@ -382,37 +413,6 @@ export const NLELayout = memo(function NLELayout({
|
|
|
382
413
|
onIframeRefStable.current?.(iframeRef.current);
|
|
383
414
|
}, [compositionStack.length, refreshKey, iframeRef]);
|
|
384
415
|
|
|
385
|
-
// Resize divider handlers
|
|
386
|
-
const handleDividerPointerDown = useCallback(
|
|
387
|
-
(e: React.PointerEvent) => {
|
|
388
|
-
if (timelineDisabled) return;
|
|
389
|
-
e.preventDefault();
|
|
390
|
-
isDragging.current = true;
|
|
391
|
-
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
392
|
-
},
|
|
393
|
-
[timelineDisabled],
|
|
394
|
-
);
|
|
395
|
-
|
|
396
|
-
const handleDividerPointerMove = useCallback(
|
|
397
|
-
(e: React.PointerEvent) => {
|
|
398
|
-
if (timelineDisabled) return;
|
|
399
|
-
if (!isDragging.current || !containerRef.current) return;
|
|
400
|
-
const rect = containerRef.current.getBoundingClientRect();
|
|
401
|
-
const mouseY = e.clientY - rect.top;
|
|
402
|
-
const containerH = rect.height;
|
|
403
|
-
const newTimelineH = Math.max(
|
|
404
|
-
MIN_TIMELINE_H,
|
|
405
|
-
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
406
|
-
);
|
|
407
|
-
setTimelineH(newTimelineH);
|
|
408
|
-
},
|
|
409
|
-
[timelineDisabled],
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
const handleDividerPointerUp = useCallback(() => {
|
|
413
|
-
isDragging.current = false;
|
|
414
|
-
}, []);
|
|
415
|
-
|
|
416
416
|
// Keyboard: Escape to pop composition level
|
|
417
417
|
const handleKeyDown = useCallback(
|
|
418
418
|
(e: React.KeyboardEvent) => {
|
|
@@ -451,6 +451,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
451
451
|
e.clientY <= rect.bottom;
|
|
452
452
|
if (!inside) onSelectTimelineElement?.(null);
|
|
453
453
|
}}
|
|
454
|
+
onDragEnter={handlePreviewDragEnter}
|
|
454
455
|
onDragOver={handlePreviewDragOver}
|
|
455
456
|
onDragLeave={handlePreviewDragLeave}
|
|
456
457
|
onDrop={handlePreviewDrop}
|
|
@@ -465,6 +466,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
465
466
|
directUrl={directUrl}
|
|
466
467
|
suppressLoadingOverlay={hasLoadedOnceRef.current}
|
|
467
468
|
onStageRef={handleStageRef}
|
|
469
|
+
onCompositionSizeChange={setPreviewCompositionSize}
|
|
468
470
|
/>
|
|
469
471
|
{previewDragOver && (
|
|
470
472
|
<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" />
|
|
@@ -491,16 +493,13 @@ export const NLELayout = memo(function NLELayout({
|
|
|
491
493
|
|
|
492
494
|
{!isFullscreen && isTimelineVisible ? (
|
|
493
495
|
<>
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
>
|
|
502
|
-
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
|
503
|
-
</div>
|
|
496
|
+
<TimelineResizeDivider
|
|
497
|
+
timelineH={timelineH}
|
|
498
|
+
setTimelineH={setTimelineH}
|
|
499
|
+
persistTimelineH={persistTimelineH}
|
|
500
|
+
containerRef={containerRef}
|
|
501
|
+
disabled={timelineDisabled}
|
|
502
|
+
/>
|
|
504
503
|
|
|
505
504
|
{/* Timeline section */}
|
|
506
505
|
<div
|
|
@@ -537,13 +536,17 @@ export const NLELayout = memo(function NLELayout({
|
|
|
537
536
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
538
537
|
{timelineDisabled && (
|
|
539
538
|
<div
|
|
540
|
-
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
|
539
|
+
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
541
540
|
data-testid="timeline-loading-disabled-overlay"
|
|
542
|
-
|
|
541
|
+
role="status"
|
|
543
542
|
onPointerDown={(event) => event.preventDefault()}
|
|
544
543
|
onDragOver={(event) => event.preventDefault()}
|
|
545
544
|
onDrop={(event) => event.preventDefault()}
|
|
546
|
-
|
|
545
|
+
>
|
|
546
|
+
<span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
|
|
547
|
+
Loading composition…
|
|
548
|
+
</span>
|
|
549
|
+
</div>
|
|
547
550
|
)}
|
|
548
551
|
</div>
|
|
549
552
|
</>
|
|
@@ -21,6 +21,8 @@ interface NLEPreviewProps {
|
|
|
21
21
|
directUrl?: string;
|
|
22
22
|
suppressLoadingOverlay?: boolean;
|
|
23
23
|
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
|
|
24
|
+
/** Reports the authored composition size measured from the loaded preview. */
|
|
25
|
+
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export function getPreviewPlayerKey({
|
|
@@ -123,6 +125,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
123
125
|
directUrl,
|
|
124
126
|
suppressLoadingOverlay,
|
|
125
127
|
onStageRef,
|
|
128
|
+
onCompositionSizeChange,
|
|
126
129
|
}: NLEPreviewProps) {
|
|
127
130
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
128
131
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -170,13 +173,22 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
170
173
|
return () => observer.disconnect();
|
|
171
174
|
}, [compositionSize, portrait]);
|
|
172
175
|
|
|
176
|
+
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
177
|
+
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
178
|
+
|
|
173
179
|
const updateCompositionSizeFromPreview = useCallback(() => {
|
|
174
180
|
const next = readPreviewCompositionSize(previewIframeRef.current);
|
|
181
|
+
// Pure updater — the parent notification happens in the effect below
|
|
182
|
+
// (updaters may run more than once under Strict Mode / concurrent React).
|
|
175
183
|
setCompositionSize((prev) =>
|
|
176
184
|
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
|
|
177
185
|
);
|
|
178
186
|
}, []);
|
|
179
187
|
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
onCompositionSizeChangeRef.current?.(compositionSize);
|
|
190
|
+
}, [compositionSize]);
|
|
191
|
+
|
|
180
192
|
const setPreviewIframeRef = useCallback(
|
|
181
193
|
(node: HTMLIFrameElement | null) => {
|
|
182
194
|
previewIframeRef.current = node;
|
|
@@ -206,10 +218,17 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
206
218
|
};
|
|
207
219
|
zoomRef.current = clamped;
|
|
208
220
|
|
|
209
|
-
if (showHud
|
|
210
|
-
zoomingRef.current = true;
|
|
221
|
+
if (showHud) {
|
|
211
222
|
const hud = hudRef.current;
|
|
212
|
-
if (hud)
|
|
223
|
+
if (hud) {
|
|
224
|
+
if (!zoomingRef.current) {
|
|
225
|
+
zoomingRef.current = true;
|
|
226
|
+
hud.style.opacity = "1";
|
|
227
|
+
}
|
|
228
|
+
// Live per-frame readout — without this the HUD shows an empty pill
|
|
229
|
+
// on the first-ever zoom and a stale percentage mid-gesture.
|
|
230
|
+
hud.textContent = isPreviewAtFit(clamped) ? "Fit" : `${Math.round(clamped.zoomPercent)}%`;
|
|
231
|
+
}
|
|
213
232
|
}
|
|
214
233
|
|
|
215
234
|
writeTransform(clamped);
|
|
@@ -254,7 +273,24 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
254
273
|
const applyInitialZoom = useCallback(() => {
|
|
255
274
|
const z = zoomRef.current;
|
|
256
275
|
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
|
|
257
|
-
|
|
276
|
+
// A pan persisted on a large window can restore the composition mostly
|
|
277
|
+
// off-screen in a smaller one; clamp against the current viewport first.
|
|
278
|
+
const viewport = viewportRef.current;
|
|
279
|
+
const rect = viewport?.getBoundingClientRect();
|
|
280
|
+
const sz = stageSizeRef.current;
|
|
281
|
+
if (rect && rect.width > 0 && rect.height > 0 && sz.width > 0 && sz.height > 0) {
|
|
282
|
+
const pan = clampPreviewPan({
|
|
283
|
+
panX: z.panX,
|
|
284
|
+
panY: z.panY,
|
|
285
|
+
zoomPercent: z.zoomPercent,
|
|
286
|
+
viewportWidth: rect.width,
|
|
287
|
+
viewportHeight: rect.height,
|
|
288
|
+
contentWidth: sz.width,
|
|
289
|
+
contentHeight: sz.height,
|
|
290
|
+
});
|
|
291
|
+
zoomRef.current = { ...z, ...pan };
|
|
292
|
+
}
|
|
293
|
+
writeTransform(zoomRef.current);
|
|
258
294
|
}
|
|
259
295
|
}, [writeTransform]);
|
|
260
296
|
|
|
@@ -474,7 +510,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
474
510
|
<div
|
|
475
511
|
ref={hudRef}
|
|
476
512
|
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
|
|
477
|
-
style={{ opacity: 0, transition: "opacity
|
|
513
|
+
style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
|
|
478
514
|
aria-live="polite"
|
|
479
515
|
/>
|
|
480
516
|
{!isPreviewAtFit(settledZoom) && (
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export const MIN_TIMELINE_H = 100;
|
|
4
|
+
export const MIN_PREVIEW_H = 120;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Horizontal drag/keyboard-resizable divider between the preview and the
|
|
8
|
+
* timeline. Implements the separator pattern: ArrowUp grows the timeline,
|
|
9
|
+
* ArrowDown shrinks it (mirrors the drag direction).
|
|
10
|
+
*/
|
|
11
|
+
export function TimelineResizeDivider({
|
|
12
|
+
timelineH,
|
|
13
|
+
setTimelineH,
|
|
14
|
+
persistTimelineH,
|
|
15
|
+
containerRef,
|
|
16
|
+
disabled,
|
|
17
|
+
}: {
|
|
18
|
+
timelineH: number;
|
|
19
|
+
setTimelineH: React.Dispatch<React.SetStateAction<number>>;
|
|
20
|
+
persistTimelineH: (h: number) => void;
|
|
21
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
const isDragging = useRef(false);
|
|
25
|
+
const timelineHRef = useRef(timelineH);
|
|
26
|
+
timelineHRef.current = timelineH;
|
|
27
|
+
|
|
28
|
+
const handlePointerDown = useCallback(
|
|
29
|
+
(e: React.PointerEvent) => {
|
|
30
|
+
if (disabled) return;
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
isDragging.current = true;
|
|
33
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
34
|
+
},
|
|
35
|
+
[disabled],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const handlePointerMove = useCallback(
|
|
39
|
+
(e: React.PointerEvent) => {
|
|
40
|
+
if (disabled) return;
|
|
41
|
+
if (!isDragging.current || !containerRef.current) return;
|
|
42
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
43
|
+
const mouseY = e.clientY - rect.top;
|
|
44
|
+
const containerH = rect.height;
|
|
45
|
+
const newTimelineH = Math.max(
|
|
46
|
+
MIN_TIMELINE_H,
|
|
47
|
+
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
48
|
+
);
|
|
49
|
+
setTimelineH(newTimelineH);
|
|
50
|
+
},
|
|
51
|
+
[disabled, containerRef, setTimelineH],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const handlePointerUp = useCallback(() => {
|
|
55
|
+
if (isDragging.current) persistTimelineH(timelineHRef.current);
|
|
56
|
+
isDragging.current = false;
|
|
57
|
+
}, [persistTimelineH]);
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = useCallback(
|
|
60
|
+
(e: React.KeyboardEvent) => {
|
|
61
|
+
if (disabled) return;
|
|
62
|
+
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
|
|
65
|
+
const delta = e.key === "ArrowUp" ? 16 : -16;
|
|
66
|
+
setTimelineH((prev) => {
|
|
67
|
+
const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
|
|
68
|
+
persistTimelineH(next);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
[disabled, containerRef, setTimelineH, persistTimelineH],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
role="separator"
|
|
78
|
+
aria-orientation="horizontal"
|
|
79
|
+
aria-label="Resize timeline (arrow keys)"
|
|
80
|
+
aria-valuenow={Math.round(timelineH)}
|
|
81
|
+
aria-valuemin={MIN_TIMELINE_H}
|
|
82
|
+
aria-valuemax={Math.round(
|
|
83
|
+
(containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
|
|
84
|
+
)}
|
|
85
|
+
tabIndex={0}
|
|
86
|
+
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10 outline-none focus-visible:bg-studio-accent/20"
|
|
87
|
+
style={{ touchAction: "none" }}
|
|
88
|
+
onPointerDown={handlePointerDown}
|
|
89
|
+
onPointerMove={handlePointerMove}
|
|
90
|
+
onPointerUp={handlePointerUp}
|
|
91
|
+
onPointerCancel={handlePointerUp}
|
|
92
|
+
onKeyDown={handleKeyDown}
|
|
93
|
+
>
|
|
94
|
+
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22 group-focus-visible:bg-studio-accent/60" />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { useCallback, useState, type RefObject } from "react";
|
|
1
|
+
import { useCallback, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
3
3
|
|
|
4
4
|
interface UsePreviewBlockDropOptions {
|
|
5
5
|
portrait?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Authored composition size measured from the live preview. Preferred over
|
|
8
|
+
* the portrait fallback — hard-coding 1080/1920 places drops at the wrong
|
|
9
|
+
* spot for any composition authored at another size (square, 720p, 4K).
|
|
10
|
+
*/
|
|
11
|
+
compositionSize?: { width: number; height: number } | null;
|
|
6
12
|
stageRef: RefObject<HTMLDivElement | null>;
|
|
7
13
|
onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
|
|
8
14
|
}
|
|
@@ -28,6 +34,7 @@ function resolveCompositionPosition(
|
|
|
28
34
|
clientX: number,
|
|
29
35
|
clientY: number,
|
|
30
36
|
stageRect: DOMRect,
|
|
37
|
+
compositionSize: { width: number; height: number } | null | undefined,
|
|
31
38
|
portrait: boolean | undefined,
|
|
32
39
|
): { left: number; top: number } | null {
|
|
33
40
|
if (stageRect.width === 0 || stageRect.height === 0) return null;
|
|
@@ -35,8 +42,8 @@ function resolveCompositionPosition(
|
|
|
35
42
|
const normalizedX = (clientX - stageRect.left) / stageRect.width;
|
|
36
43
|
const normalizedY = (clientY - stageRect.top) / stageRect.height;
|
|
37
44
|
|
|
38
|
-
const compWidth = portrait ? 1080 : 1920;
|
|
39
|
-
const compHeight = portrait ? 1920 : 1080;
|
|
45
|
+
const compWidth = compositionSize?.width ?? (portrait ? 1080 : 1920);
|
|
46
|
+
const compHeight = compositionSize?.height ?? (portrait ? 1920 : 1080);
|
|
40
47
|
|
|
41
48
|
return {
|
|
42
49
|
left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
|
|
@@ -58,10 +65,24 @@ function centerBlockAtPosition(
|
|
|
58
65
|
|
|
59
66
|
export function usePreviewBlockDrop({
|
|
60
67
|
portrait,
|
|
68
|
+
compositionSize,
|
|
61
69
|
stageRef,
|
|
62
70
|
onBlockDrop,
|
|
63
71
|
}: UsePreviewBlockDropOptions) {
|
|
64
72
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
73
|
+
// dragenter/dragleave fire for every internal element boundary; a depth
|
|
74
|
+
// counter keeps the drop indicator steady instead of flickering.
|
|
75
|
+
const dragDepthRef = useRef(0);
|
|
76
|
+
|
|
77
|
+
const handleDragEnter = useCallback(
|
|
78
|
+
(e: React.DragEvent) => {
|
|
79
|
+
if (!onBlockDrop) return;
|
|
80
|
+
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
|
|
81
|
+
dragDepthRef.current += 1;
|
|
82
|
+
setIsDragOver(true);
|
|
83
|
+
},
|
|
84
|
+
[onBlockDrop],
|
|
85
|
+
);
|
|
65
86
|
|
|
66
87
|
const handleDragOver = useCallback(
|
|
67
88
|
(e: React.DragEvent) => {
|
|
@@ -69,18 +90,20 @@ export function usePreviewBlockDrop({
|
|
|
69
90
|
if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
|
|
70
91
|
e.preventDefault();
|
|
71
92
|
e.dataTransfer.dropEffect = "copy";
|
|
72
|
-
|
|
93
|
+
// dragenter/dragleave own the isDragOver flag (depth-counted).
|
|
73
94
|
},
|
|
74
95
|
[onBlockDrop],
|
|
75
96
|
);
|
|
76
97
|
|
|
77
98
|
const handleDragLeave = useCallback(() => {
|
|
78
|
-
|
|
99
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
100
|
+
if (dragDepthRef.current === 0) setIsDragOver(false);
|
|
79
101
|
}, []);
|
|
80
102
|
|
|
81
103
|
// fallow-ignore-next-line complexity
|
|
82
104
|
const handleDrop = useCallback(
|
|
83
105
|
(e: React.DragEvent) => {
|
|
106
|
+
dragDepthRef.current = 0;
|
|
84
107
|
setIsDragOver(false);
|
|
85
108
|
if (!onBlockDrop) return;
|
|
86
109
|
|
|
@@ -96,14 +119,15 @@ export function usePreviewBlockDrop({
|
|
|
96
119
|
e.clientX,
|
|
97
120
|
e.clientY,
|
|
98
121
|
stage.getBoundingClientRect(),
|
|
122
|
+
compositionSize,
|
|
99
123
|
portrait,
|
|
100
124
|
);
|
|
101
125
|
if (!pos) return;
|
|
102
126
|
|
|
103
127
|
onBlockDrop(block.name, centerBlockAtPosition(pos, block));
|
|
104
128
|
},
|
|
105
|
-
[onBlockDrop, stageRef, portrait],
|
|
129
|
+
[onBlockDrop, stageRef, compositionSize, portrait],
|
|
106
130
|
);
|
|
107
131
|
|
|
108
|
-
return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
|
|
132
|
+
return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
|
|
109
133
|
}
|