@hyperframes/studio 0.7.56 → 0.7.57

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.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -3,35 +3,27 @@ import { useCallback, useRef } from "react";
3
3
  import type { TimelineElement } from "../player";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { useRazorSplit } from "./useRazorSplit";
6
- import {
7
- buildTimelineAssetId,
8
- buildTimelineAssetInsertHtml,
9
- buildTimelineFileDropPlacements,
10
- getTimelineAssetKind,
11
- insertTimelineAssetIntoSource,
12
- resolveTimelineAssetInitialGeometry,
13
- resolveTimelineAssetSrc,
14
- } from "../utils/timelineAssetDrop";
6
+ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
15
7
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
16
- import {
17
- getTimelineElementLabel,
18
- collectHtmlIds,
19
- resolveDroppedAssetDuration,
20
- } from "../utils/studioHelpers";
8
+ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
9
+ import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
10
+ import { getTimelineElementLabel } from "../utils/studioHelpers";
21
11
  import {
22
12
  applyTimelineStackingReorder,
23
13
  buildPatchTarget,
24
14
  patchIframeDomTiming,
25
15
  persistTimelineEdit,
26
- readFileContent,
27
- foldedShiftGsapMutation,
28
- foldedScaleGsapMutation,
29
16
  formatTimelineAttributeNumber,
30
- finishTimelineTimingFallback,
31
17
  extendRootDurationIfNeeded,
32
18
  buildTimelineMoveTimingPatch,
33
19
  buildTimelineResizeTimingPatch,
34
20
  } from "./timelineEditingHelpers";
21
+ import {
22
+ captureDurationRollback,
23
+ finishClipTimingFallback,
24
+ readFileContent,
25
+ syncPreviewContentDuration,
26
+ } from "./timelineTimingSync";
35
27
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
36
28
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
37
29
  import {
@@ -137,11 +129,22 @@ export function useTimelineEditing({
137
129
  (element: TimelineElement, updates: TimelineMoveUpdates) => {
138
130
  const targetPath = element.sourceFile || activeCompPath || "index.html";
139
131
  const startChanged = updates.start !== element.start;
132
+ // A vertical-only lane move arrives with start unchanged but track changed
133
+ // (on this single-element path the drag commit has already folded the
134
+ // AUTHORED persist track into updates.track). It must persist like any
135
+ // other move — early-returning on !startChanged alone silently dropped
136
+ // the file write, so the lane snapped back on reload.
137
+ const trackChanged = updates.track !== element.track;
140
138
 
141
- if (startChanged) {
142
- patchIframeDomTiming(previewIframeRef.current, element, [
143
- ["data-start", formatTimelineAttributeNumber(updates.start)],
144
- ]);
139
+ if (startChanged || trackChanged) {
140
+ const liveAttrs: Array<[string, string]> = [];
141
+ if (startChanged) {
142
+ liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
143
+ }
144
+ if (trackChanged) {
145
+ liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
146
+ }
147
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
145
148
  }
146
149
 
147
150
  const reorderDone = applyTimelineStackingReorder({
@@ -153,62 +156,76 @@ export function useTimelineEditing({
153
156
  commit: handleDomZIndexReorderCommitRef?.current,
154
157
  });
155
158
 
156
- if (!startChanged) return reorderDone;
159
+ if (!startChanged && !trackChanged) return reorderDone;
160
+
161
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
162
+ // persist can roll the readout + live root back (see captureDurationRollback).
163
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
164
+ // needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
165
+ const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
166
+ // Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
167
+ syncPreviewContentDuration(previewIframeRef.current);
157
168
 
158
169
  const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
159
- return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
170
+ // Persist lane changes too — data-start-only writes let reload snap the lane back.
171
+ const track = trackChanged ? updates.track : undefined;
172
+ return buildTimelineMoveTimingPatch(
173
+ original,
174
+ target,
175
+ updates.start,
176
+ element.duration,
177
+ track,
178
+ );
160
179
  };
161
180
  const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
162
181
  const moveFallback = () =>
163
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
164
- const pid = projectIdRef.current;
165
- const delta = updates.start - element.start;
166
- const domId = element.domId;
167
- return finishTimelineTimingFallback({
182
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
183
+ // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
184
+ // DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
185
+ finishClipTimingFallback({
168
186
  iframe: previewIframeRef.current,
169
- needsExtension,
170
- rootDurationSeconds: updates.start + element.duration,
171
187
  reloadPreview,
172
- gsapMutation:
173
- delta !== 0 && domId && pid
174
- ? foldedShiftGsapMutation({
175
- projectId: pid,
176
- targetPath,
177
- domId,
178
- delta,
179
- label: "Move timeline clip",
180
- coalesceKey,
181
- recordEdit,
182
- })
183
- : undefined,
184
- onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
185
- });
186
- });
187
- const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
188
- return reorderDone.then(() => {
189
- if (sdkSession && element.hfId && !needsExtension) {
190
- return sdkTimingPersist(
191
- element.hfId,
188
+ projectId: projectIdRef.current,
192
189
  targetPath,
193
- { start: updates.start },
194
- sdkSession,
195
- {
196
- editHistory: { recordEdit },
197
- writeProjectFile,
198
- reloadPreview,
199
- domEditSaveTimestampRef,
200
- compositionPath: activeCompPath,
201
- // Capture on-disk bytes as the undo `before` so undoing a timing move
202
- // restores the file verbatim, not a normalized full-DOM re-emit.
203
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
204
- },
205
- { label: "Move timeline clip", coalesceKey },
206
- ).then((handled) => {
207
- if (!handled) return moveFallback();
208
- });
209
- }
210
- return moveFallback();
211
- });
190
+ domId: element.domId,
191
+ label: "Move timeline clip",
192
+ coalesceKey,
193
+ recordEdit,
194
+ edit: { kind: "shift", delta: updates.start - element.start },
195
+ }),
196
+ );
197
+ return reorderDone
198
+ .then(() => {
199
+ // The SDK setTiming path writes start only — a lane change must take
200
+ // the fallback, whose patch builder writes data-track-index too.
201
+ if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
202
+ return sdkTimingPersist(
203
+ element.hfId,
204
+ targetPath,
205
+ { start: updates.start },
206
+ sdkSession,
207
+ {
208
+ editHistory: { recordEdit },
209
+ writeProjectFile,
210
+ reloadPreview,
211
+ domEditSaveTimestampRef,
212
+ compositionPath: activeCompPath,
213
+ // Capture on-disk bytes as the undo `before` so undoing a timing move
214
+ // restores the file verbatim, not a normalized full-DOM re-emit.
215
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
216
+ },
217
+ { label: "Move timeline clip", coalesceKey },
218
+ ).then((handled) => {
219
+ if (!handled) return moveFallback();
220
+ });
221
+ }
222
+ return moveFallback();
223
+ })
224
+ .catch((error) => {
225
+ // Failed persist: revert the optimistic duration readout + live root.
226
+ rollbackDuration();
227
+ throw error;
228
+ });
212
229
  },
213
230
  [
214
231
  previewIframeRef,
@@ -245,6 +262,13 @@ export function useTimelineEditing({
245
262
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
246
263
  }
247
264
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
265
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
266
+ // persist can roll the readout + live root back (see captureDurationRollback).
267
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
268
+ // needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
269
+ const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
270
+ // Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
271
+ syncPreviewContentDuration(previewIframeRef.current);
248
272
  const targetPath = element.sourceFile || activeCompPath || "index.html";
249
273
  const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
250
274
  return buildTimelineResizeTimingPatch(original, target, element, updates);
@@ -253,59 +277,55 @@ export function useTimelineEditing({
253
277
  updates.playbackStart != null ||
254
278
  (updates.start !== element.start && element.playbackStart != null);
255
279
  // Server-path fallback: after persisting the attr patch, scale GSAP tween
256
- // positions/durations on the server. Extending edits can keep the iframe
257
- // live unless a GSAP source rewrite needs a fresh run.
280
+ // positions/durations on the server, then soft-reload with the rewritten
281
+ // script (timing-only resize) same no-flash path as move; full reload is
282
+ // the fallback.
258
283
  const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
259
- const timingChanged =
260
- updates.start !== element.start || updates.duration !== element.duration;
261
- const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
262
284
  const resizeFallback = () =>
263
- enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
264
- const pid = projectIdRef.current;
265
- const domId = element.domId;
266
- return finishTimelineTimingFallback({
285
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
286
+ finishClipTimingFallback({
267
287
  iframe: previewIframeRef.current,
268
- needsExtension,
269
- rootDurationSeconds: updates.start + updates.duration,
270
288
  reloadPreview,
271
- gsapMutation:
272
- timingChanged && domId && pid
273
- ? foldedScaleGsapMutation({
274
- projectId: pid,
275
- targetPath,
276
- domId,
277
- from: { start: element.start, duration: element.duration },
278
- to: { start: updates.start, duration: updates.duration },
279
- label: "Resize timeline clip",
280
- coalesceKey,
281
- recordEdit,
282
- })
283
- : undefined,
284
- onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
285
- });
286
- });
287
- if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
288
- return sdkTimingPersist(
289
- element.hfId,
290
- targetPath,
291
- { start: updates.start, duration: updates.duration },
292
- sdkSession,
293
- {
294
- editHistory: { recordEdit },
295
- writeProjectFile,
296
- reloadPreview,
297
- domEditSaveTimestampRef,
298
- compositionPath: activeCompPath,
299
- // Capture on-disk bytes as the undo `before` so undoing a timing
300
- // resize restores the file verbatim, not a normalized full-DOM re-emit.
301
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
302
- },
303
- { label: "Resize timeline clip", coalesceKey },
304
- ).then((handled) => {
305
- if (!handled) return resizeFallback();
306
- });
307
- }
308
- return resizeFallback();
289
+ projectId: projectIdRef.current,
290
+ targetPath,
291
+ domId: element.domId,
292
+ label: "Resize timeline clip",
293
+ coalesceKey,
294
+ recordEdit,
295
+ edit: {
296
+ kind: "scale",
297
+ from: { start: element.start, duration: element.duration },
298
+ to: { start: updates.start, duration: updates.duration },
299
+ },
300
+ }),
301
+ );
302
+ const persistDone =
303
+ sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
304
+ ? sdkTimingPersist(
305
+ element.hfId,
306
+ targetPath,
307
+ { start: updates.start, duration: updates.duration },
308
+ sdkSession,
309
+ {
310
+ editHistory: { recordEdit },
311
+ writeProjectFile,
312
+ reloadPreview,
313
+ domEditSaveTimestampRef,
314
+ compositionPath: activeCompPath,
315
+ // Capture on-disk bytes as the undo `before` so undoing a timing
316
+ // resize restores the file verbatim, not a normalized full-DOM re-emit.
317
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
318
+ },
319
+ { label: "Resize timeline clip", coalesceKey },
320
+ ).then((handled) => {
321
+ if (!handled) return resizeFallback();
322
+ })
323
+ : resizeFallback();
324
+ return persistDone.catch((error) => {
325
+ // Failed persist: revert the optimistic duration readout + live root.
326
+ rollbackDuration();
327
+ throw error;
328
+ });
309
329
  },
310
330
  [
311
331
  previewIframeRef,
@@ -384,19 +404,37 @@ export function useTimelineEditing({
384
404
  changed?: boolean;
385
405
  content?: string;
386
406
  };
387
- const patchedContent =
407
+ const removedContent =
388
408
  typeof removeData.content === "string" ? removeData.content : originalContent;
409
+ // Content-driven duration: shrink the composition to the furthest
410
+ // remaining clip end, read from the post-removal SOURCE (raw
411
+ // data-duration), so deleting the last/longest clip removes trailing
412
+ // empty space. Measured from the source, not the store, whose
413
+ // durations are runtime-truncated.
414
+ const deleteContentEnd = furthestClipEndFromSource(removedContent);
415
+ const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
416
+ // Optimistically reflect the shrunk length in the readout/seek bar,
417
+ // rolling it back if the persist below fails (see captureDurationRollback).
418
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
419
+ if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
420
+ usePlayerStore.getState().setDuration(deleteContentEnd);
421
+ }
389
422
 
390
423
  domEditSaveTimestampRef.current = Date.now();
391
- await saveProjectFilesWithHistory({
392
- projectId: pid,
393
- label: "Delete timeline clip",
394
- kind: "timeline",
395
- files: { [targetPath]: patchedContent },
396
- readFile: async () => originalContent,
397
- writeFile: writeProjectFile,
398
- recordEdit,
399
- });
424
+ try {
425
+ await saveProjectFilesWithHistory({
426
+ projectId: pid,
427
+ label: "Delete timeline clip",
428
+ kind: "timeline",
429
+ files: { [targetPath]: patchedContent },
430
+ readFile: async () => originalContent,
431
+ writeFile: writeProjectFile,
432
+ recordEdit,
433
+ });
434
+ } catch (error) {
435
+ rollbackDuration();
436
+ throw error;
437
+ }
400
438
 
401
439
  usePlayerStore
402
440
  .getState()
@@ -422,145 +460,23 @@ export function useTimelineEditing({
422
460
  reloadPreview,
423
461
  isRecordingRef,
424
462
  forceReloadSdkSession,
463
+ previewIframeRef,
425
464
  ],
426
465
  );
427
466
 
428
- // fallow-ignore-next-line complexity
429
- const handleTimelineAssetDrop = useCallback(
430
- // fallow-ignore-next-line complexity
431
- async (
432
- assetPath: string,
433
- placement: Pick<TimelineElement, "start" | "track">,
434
- durationOverride?: number,
435
- ) => {
436
- if (isRecordingRef?.current) {
437
- showToast("Cannot edit timeline while recording", "error");
438
- return;
439
- }
440
- const pid = projectIdRef.current;
441
- if (!pid) throw new Error("No active project");
442
-
443
- const kind = getTimelineAssetKind(assetPath);
444
- if (!kind) {
445
- showToast("Only image, video, and audio assets can be dropped onto the timeline.");
446
- return;
447
- }
448
-
449
- const targetPath = activeCompPath || "index.html";
450
- try {
451
- const originalContent = await readFileContent(pid, targetPath);
452
-
453
- const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
454
- const duration =
455
- Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
456
- ? durationOverride
457
- : await resolveDroppedAssetDuration(pid, assetPath, kind);
458
- const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
459
- const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
460
- const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
461
-
462
- const resolvedTargetPath = targetPath || "index.html";
463
- const relevantElements = timelineElements.filter(
464
- (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
465
- );
466
- const newElementZIndex = Math.max(1, relevantElements.length + 1);
467
-
468
- const patchedContent = insertTimelineAssetIntoSource(
469
- originalContent,
470
- buildTimelineAssetInsertHtml({
471
- id: newId,
472
- assetPath: resolvedAssetSrc,
473
- kind,
474
- start: normalizedStart,
475
- duration: normalizedDuration,
476
- track: placement.track,
477
- zIndex: newElementZIndex,
478
- geometry: resolveTimelineAssetInitialGeometry(originalContent),
479
- }),
480
- );
481
-
482
- domEditSaveTimestampRef.current = Date.now();
483
- await saveProjectFilesWithHistory({
484
- projectId: pid,
485
- label: "Add timeline asset",
486
- kind: "timeline",
487
- files: { [targetPath]: patchedContent },
488
- readFile: async () => originalContent,
489
- writeFile: writeProjectFile,
490
- recordEdit,
491
- });
492
-
493
- forceReloadSdkSession?.();
494
- reloadPreview();
495
- } catch (error) {
496
- const message =
497
- error instanceof Error ? error.message : "Failed to drop asset onto timeline";
498
- showToast(message);
499
- }
500
- },
501
- [
502
- activeCompPath,
503
- recordEdit,
504
- showToast,
505
- timelineElements,
506
- writeProjectFile,
507
- domEditSaveTimestampRef,
508
- reloadPreview,
509
- isRecordingRef,
510
- forceReloadSdkSession,
511
- ],
512
- );
513
-
514
- // fallow-ignore-next-line complexity
515
- const handleTimelineFileDrop = useCallback(
516
- // fallow-ignore-next-line complexity
517
- async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
518
- if (isRecordingRef?.current) {
519
- showToast("Cannot edit timeline while recording", "error");
520
- return;
521
- }
522
- const pid = projectIdRef.current;
523
- if (!pid) return;
524
- const uploaded = await uploadProjectFiles(files);
525
- if (uploaded.length === 0) return;
526
- const durations: number[] = [];
527
- for (const assetPath of uploaded) {
528
- const kind = getTimelineAssetKind(assetPath);
529
- const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
530
- durations.push(Number(formatTimelineAttributeNumber(duration)));
531
- }
532
- const placements = buildTimelineFileDropPlacements(
533
- placement ?? { start: 0, track: 0 },
534
- durations,
535
- timelineElements
536
- .filter(
537
- (te) =>
538
- (te.sourceFile || activeCompPath || "index.html") ===
539
- (activeCompPath || "index.html"),
540
- )
541
- .map((te) => ({
542
- start: te.start,
543
- duration: te.duration,
544
- track: te.track,
545
- })),
546
- );
547
- for (const [index, assetPath] of uploaded.entries()) {
548
- await handleTimelineAssetDrop(
549
- assetPath,
550
- placements[index] ?? placements[0],
551
- durations[index],
552
- );
553
- }
554
- },
555
- [
556
- activeCompPath,
557
- handleTimelineAssetDrop,
558
- timelineElements,
559
- uploadProjectFiles,
560
- isRecordingRef,
561
- showToast,
562
- ],
563
- );
467
+ const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
468
+ projectIdRef,
469
+ activeCompPath,
470
+ timelineElements,
471
+ showToast,
472
+ writeProjectFile,
473
+ recordEdit,
474
+ domEditSaveTimestampRef,
475
+ reloadPreview,
476
+ uploadProjectFiles,
477
+ isRecordingRef,
478
+ forceReloadSdkSession,
479
+ });
564
480
 
565
481
  const handleBlockedTimelineEdit = useCallback(
566
482
  (_element: TimelineElement) => {