@hyperframes/studio 0.6.31 → 0.6.33

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.
@@ -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 { usePreviewBlockDrop } from "./usePreviewBlockDrop";
16
17
  import { useCompositionStack } from "./useCompositionStack";
17
18
  import {
18
19
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
@@ -54,6 +55,10 @@ interface NLELayoutProps {
54
55
  blockName: string,
55
56
  placement: Pick<TimelineElement, "start" | "track">,
56
57
  ) => Promise<void> | void;
58
+ onPreviewBlockDrop?: (
59
+ blockName: string,
60
+ position: { left: number; top: number },
61
+ ) => Promise<void> | void;
57
62
  /** Persist timeline move actions back into source HTML */
58
63
  onMoveElement?: (
59
64
  element: TimelineElement,
@@ -107,6 +112,7 @@ export const NLELayout = memo(function NLELayout({
107
112
  onDeleteElement,
108
113
  onAssetDrop,
109
114
  onBlockDrop,
115
+ onPreviewBlockDrop,
110
116
  onMoveElement,
111
117
  onResizeElement,
112
118
  onBlockedEditAttempt,
@@ -131,10 +137,26 @@ export const NLELayout = memo(function NLELayout({
131
137
  usePlayerStore.getState().reset();
132
138
  }
133
139
 
140
+ const stageRefForDrop = useRef<HTMLDivElement | null>(null);
141
+ const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
142
+ stageRefForDrop.current = ref.current;
143
+ }, []);
144
+
145
+ const {
146
+ isDragOver: previewDragOver,
147
+ handleDragOver: handlePreviewDragOver,
148
+ handleDragLeave: handlePreviewDragLeave,
149
+ handleDrop: handlePreviewDrop,
150
+ } = usePreviewBlockDrop({
151
+ portrait,
152
+ stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
153
+ onBlockDrop: onPreviewBlockDrop,
154
+ });
155
+
134
156
  // Lightweight reload: change iframe src instead of destroying the Player.
135
157
  // refreshPlayer() saves the seek position and appends a cache-busting _t
136
- // param, avoiding the full web-component teardown + crossfade that the
137
- // key-based path uses.
158
+ // param the Player instance stays alive so the adapter is available for
159
+ // saveSeekPosition() to read the current time before the reload.
138
160
  const prevRefreshKeyRef = useRef(refreshKey);
139
161
  useEffect(() => {
140
162
  if (refreshKey === prevRefreshKeyRef.current) return;
@@ -344,7 +366,13 @@ export const NLELayout = memo(function NLELayout({
344
366
  >
345
367
  {/* Preview + player controls */}
346
368
  <div className="flex-1 min-h-0 flex flex-col">
347
- <div className="flex-1 min-h-0 relative" data-preview-pan-surface="true">
369
+ <div
370
+ className="flex-1 min-h-0 relative"
371
+ data-preview-pan-surface="true"
372
+ onDragOver={handlePreviewDragOver}
373
+ onDragLeave={handlePreviewDragLeave}
374
+ onDrop={handlePreviewDrop}
375
+ >
348
376
  <NLEPreview
349
377
  projectId={projectId}
350
378
  iframeRef={iframeRef}
@@ -352,9 +380,12 @@ export const NLELayout = memo(function NLELayout({
352
380
  onCompositionLoadingChange={setCompositionLoading}
353
381
  portrait={portrait}
354
382
  directUrl={directUrl}
355
- refreshKey={refreshKey}
356
383
  suppressLoadingOverlay={hasLoadedOnceRef.current}
384
+ onStageRef={handleStageRef}
357
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
+ )}
358
389
  {!isFullscreen && previewOverlay}
359
390
  </div>
360
391
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
@@ -112,17 +112,9 @@ function renderPreview() {
112
112
  }
113
113
 
114
114
  describe("getPreviewPlayerKey", () => {
115
- it("keeps the same player identity when only refreshKey changes", () => {
116
- expect(
117
- getPreviewPlayerKey({
118
- projectId: "timeline-edit-playground",
119
- refreshKey: 1,
120
- }),
121
- ).toBe(
122
- getPreviewPlayerKey({
123
- projectId: "timeline-edit-playground",
124
- refreshKey: 2,
125
- }),
115
+ it("uses projectId as key when no directUrl", () => {
116
+ expect(getPreviewPlayerKey({ projectId: "timeline-edit-playground" })).toBe(
117
+ "timeline-edit-playground",
126
118
  );
127
119
  });
128
120
 
@@ -12,7 +12,6 @@ import {
12
12
  type PreviewZoomState,
13
13
  } from "./previewZoom";
14
14
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
15
-
16
15
  interface NLEPreviewProps {
17
16
  projectId: string;
18
17
  iframeRef: Ref<HTMLIFrameElement>;
@@ -20,8 +19,8 @@ interface NLEPreviewProps {
20
19
  onCompositionLoadingChange?: (loading: boolean) => void;
21
20
  portrait?: boolean;
22
21
  directUrl?: string;
23
- refreshKey?: number;
24
22
  suppressLoadingOverlay?: boolean;
23
+ onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
25
24
  }
26
25
 
27
26
  export function getPreviewPlayerKey({
@@ -30,7 +29,6 @@ export function getPreviewPlayerKey({
30
29
  }: {
31
30
  projectId: string;
32
31
  directUrl?: string;
33
- refreshKey?: number;
34
32
  }): string {
35
33
  return directUrl ?? projectId;
36
34
  }
@@ -91,16 +89,16 @@ export const NLEPreview = memo(function NLEPreview({
91
89
  onCompositionLoadingChange,
92
90
  portrait,
93
91
  directUrl,
94
- refreshKey,
95
92
  suppressLoadingOverlay,
93
+ onStageRef,
96
94
  }: NLEPreviewProps) {
97
- const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
98
- const prevRefreshKeyRef = useRef(refreshKey);
95
+ const activeKey = getPreviewPlayerKey({ projectId, directUrl });
99
96
  const viewportRef = useRef<HTMLDivElement>(null);
100
97
  const stageRef = useRef<HTMLDivElement>(null);
101
- const [retiringKey, setRetiringKey] = useState<string | null>(null);
98
+ useEffect(() => {
99
+ onStageRef?.(stageRef);
100
+ }, [onStageRef]);
102
101
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
103
- const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
104
102
 
105
103
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
106
104
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -120,7 +118,6 @@ export const NLEPreview = memo(function NLEPreview({
120
118
  return () => {
121
119
  if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
122
120
  if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
123
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
124
121
  };
125
122
  }, []);
126
123
 
@@ -205,14 +202,6 @@ export const NLEPreview = memo(function NLEPreview({
205
202
  [applyTransform],
206
203
  );
207
204
 
208
- if (refreshKey !== prevRefreshKeyRef.current) {
209
- const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
210
- prevRefreshKeyRef.current = refreshKey;
211
- setRetiringKey(oldKey);
212
- }
213
-
214
- const activeKey = `${baseKey}:${refreshKey ?? 0}`;
215
-
216
205
  const applyInitialZoom = useCallback(() => {
217
206
  const z = zoomRef.current;
218
207
  if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
@@ -220,16 +209,6 @@ export const NLEPreview = memo(function NLEPreview({
220
209
  }
221
210
  }, [writeTransform]);
222
211
 
223
- const handleNewPlayerLoad = () => {
224
- onIframeLoad();
225
- applyInitialZoom();
226
- if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
227
- retiringTimerRef.current = setTimeout(() => {
228
- setRetiringKey(null);
229
- retiringTimerRef.current = null;
230
- }, 160);
231
- };
232
-
233
212
  useEffect(() => {
234
213
  const viewport = viewportRef.current;
235
214
  if (!viewport) return;
@@ -412,14 +391,14 @@ export const NLEPreview = memo(function NLEPreview({
412
391
  }}
413
392
  data-testid="preview-zoom-stage"
414
393
  >
415
- {retiringKey && (
394
+ {directUrl?.includes("/components/") && (
416
395
  <Player
417
- key={retiringKey}
418
- projectId={directUrl ? undefined : projectId}
419
- directUrl={directUrl}
396
+ key={`backdrop-${projectId}`}
397
+ projectId={projectId}
420
398
  onLoad={() => {}}
421
399
  portrait={portrait}
422
- style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
400
+ suppressLoadingOverlay
401
+ style={{ position: "absolute", inset: 0, zIndex: 0 }}
423
402
  />
424
403
  )}
425
404
  <Player
@@ -427,18 +406,18 @@ export const NLEPreview = memo(function NLEPreview({
427
406
  ref={iframeRef}
428
407
  projectId={directUrl ? undefined : projectId}
429
408
  directUrl={directUrl}
430
- onLoad={
431
- retiringKey
432
- ? handleNewPlayerLoad
433
- : () => {
434
- onIframeLoad();
435
- applyInitialZoom();
436
- }
437
- }
409
+ onLoad={() => {
410
+ onIframeLoad();
411
+ applyInitialZoom();
412
+ }}
438
413
  onCompositionLoadingChange={onCompositionLoadingChange}
439
414
  portrait={portrait}
440
- style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
441
415
  suppressLoadingOverlay={suppressLoadingOverlay}
416
+ style={
417
+ directUrl?.includes("/components/")
418
+ ? { position: "absolute", inset: 0, zIndex: 1 }
419
+ : undefined
420
+ }
442
421
  />
443
422
  </div>
444
423
  </div>
@@ -0,0 +1,109 @@
1
+ import { useCallback, useState, type RefObject } from "react";
2
+ import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+
4
+ interface UsePreviewBlockDropOptions {
5
+ portrait?: boolean;
6
+ stageRef: RefObject<HTMLDivElement | null>;
7
+ onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
8
+ }
9
+
10
+ interface BlockDropPayload {
11
+ name: string;
12
+ dimensions?: { width: number; height: number };
13
+ }
14
+
15
+ function parseBlockPayload(raw: string): BlockDropPayload | null {
16
+ try {
17
+ const parsed = JSON.parse(raw) as {
18
+ name?: string;
19
+ dimensions?: { width: number; height: number };
20
+ };
21
+ return parsed.name ? (parsed as BlockDropPayload) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function resolveCompositionPosition(
28
+ clientX: number,
29
+ clientY: number,
30
+ stageRect: DOMRect,
31
+ portrait: boolean | undefined,
32
+ ): { left: number; top: number } | null {
33
+ if (stageRect.width === 0 || stageRect.height === 0) return null;
34
+
35
+ const normalizedX = (clientX - stageRect.left) / stageRect.width;
36
+ const normalizedY = (clientY - stageRect.top) / stageRect.height;
37
+
38
+ const compWidth = portrait ? 1080 : 1920;
39
+ const compHeight = portrait ? 1920 : 1080;
40
+
41
+ return {
42
+ left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
43
+ top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)),
44
+ };
45
+ }
46
+
47
+ function centerBlockAtPosition(
48
+ pos: { left: number; top: number },
49
+ block: BlockDropPayload,
50
+ ): { left: number; top: number } {
51
+ const blockW = block.dimensions?.width ?? 0;
52
+ const blockH = block.dimensions?.height ?? 0;
53
+ return {
54
+ left: Math.max(0, pos.left - blockW / 2),
55
+ top: Math.max(0, pos.top - blockH / 2),
56
+ };
57
+ }
58
+
59
+ export function usePreviewBlockDrop({
60
+ portrait,
61
+ stageRef,
62
+ onBlockDrop,
63
+ }: UsePreviewBlockDropOptions) {
64
+ const [isDragOver, setIsDragOver] = useState(false);
65
+
66
+ const handleDragOver = useCallback(
67
+ (e: React.DragEvent) => {
68
+ if (!onBlockDrop) return;
69
+ if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
70
+ e.preventDefault();
71
+ e.dataTransfer.dropEffect = "copy";
72
+ setIsDragOver(true);
73
+ },
74
+ [onBlockDrop],
75
+ );
76
+
77
+ const handleDragLeave = useCallback(() => {
78
+ setIsDragOver(false);
79
+ }, []);
80
+
81
+ // fallow-ignore-next-line complexity
82
+ const handleDrop = useCallback(
83
+ (e: React.DragEvent) => {
84
+ setIsDragOver(false);
85
+ if (!onBlockDrop) return;
86
+
87
+ const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
88
+ if (!payload) return;
89
+ e.preventDefault();
90
+
91
+ const block = parseBlockPayload(payload);
92
+ const stage = stageRef.current;
93
+ if (!block || !stage) return;
94
+
95
+ const pos = resolveCompositionPosition(
96
+ e.clientX,
97
+ e.clientY,
98
+ stage.getBoundingClientRect(),
99
+ portrait,
100
+ );
101
+ if (!pos) return;
102
+
103
+ onBlockDrop(block.name, centerBlockAtPosition(pos, block));
104
+ },
105
+ [onBlockDrop, stageRef, portrait],
106
+ );
107
+
108
+ return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
109
+ }