@hyperframes/studio 0.7.61 → 0.7.62

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 (84) hide show
  1. package/dist/assets/{hyperframes-player-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
  2. package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3504 -2955
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -10
  12. package/src/components/EditorShell.tsx +12 -0
  13. package/src/components/StudioLeftSidebar.tsx +3 -0
  14. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  15. package/src/components/editor/PropertyPanelFlat.tsx +6 -0
  16. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  17. package/src/components/editor/domEditing.test.ts +34 -0
  18. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  19. package/src/components/editor/propertyPanelColor.tsx +150 -28
  20. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
  23. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
  24. package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
  25. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  26. package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
  27. package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
  28. package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
  29. package/src/components/editor/propertyPanelSections.tsx +2 -2
  30. package/src/components/editor/propertyPanelTypes.ts +2 -0
  31. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  33. package/src/components/nle/TimelinePane.tsx +6 -0
  34. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  35. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  36. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  37. package/src/contexts/StudioContext.tsx +4 -0
  38. package/src/hooks/timelineEditingHelpers.ts +10 -4
  39. package/src/hooks/usePreviewPersistence.ts +7 -1
  40. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  41. package/src/hooks/useRazorSplit.test.tsx +20 -9
  42. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  43. package/src/hooks/useRazorSplit.ts +78 -350
  44. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  45. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  46. package/src/hooks/useTimelineEditing.ts +19 -17
  47. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  48. package/src/player/components/Timeline.tsx +12 -1
  49. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  50. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  51. package/src/player/components/timelineCallbacks.ts +4 -0
  52. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  53. package/src/player/components/timelineClipDragCommit.ts +77 -105
  54. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  55. package/src/player/components/timelineClipDragPreview.ts +2 -1
  56. package/src/player/components/timelineCollision.test.ts +18 -0
  57. package/src/player/components/timelineCollision.ts +12 -9
  58. package/src/player/components/timelineDragDrop.ts +28 -7
  59. package/src/player/components/timelineEditCapabilities.ts +4 -1
  60. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  61. package/src/player/components/timelineGroupEditing.ts +4 -3
  62. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  63. package/src/player/components/timelineLayout.ts +3 -1
  64. package/src/player/components/timelineZMirror.ts +1 -1
  65. package/src/player/components/useTimelineClipDrag.ts +5 -0
  66. package/src/player/components/useTimelineEditPinning.ts +12 -0
  67. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  68. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  69. package/src/player/lib/playbackTypes.ts +2 -0
  70. package/src/player/lib/timelineDOM.test.ts +58 -0
  71. package/src/player/lib/timelineDOM.ts +18 -1
  72. package/src/player/lib/timelineElementHelpers.ts +21 -11
  73. package/src/player/store/playerStore.ts +2 -1
  74. package/src/utils/razorSplitTransaction.test.ts +175 -0
  75. package/src/utils/razorSplitTransaction.ts +200 -0
  76. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  77. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  78. package/src/utils/timelineCompositionDrop.ts +16 -0
  79. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  80. package/src/utils/timelineCompositionInsert.ts +90 -0
  81. package/src/utils/timelineElementSplit.test.ts +14 -2
  82. package/src/utils/timelineElementSplit.ts +5 -1
  83. package/dist/assets/index-2mxh_HSy.js +0 -426
  84. package/dist/assets/index-BhWig0mx.css +0 -1
@@ -1,15 +1,10 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
- import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
4
+ import { getTimelineElementLabel } from "../utils/studioHelpers";
6
5
  import { trackStudioRazorSplit } from "../telemetry/events";
7
- import {
8
- canSplitElementAt,
9
- selectSplittableElements,
10
- buildPatchTarget,
11
- readFileContent,
12
- } from "../utils/timelineElementSplit";
6
+ import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
7
+ import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
13
8
  import type { RecordEditInput } from "./timelineEditingHelpers";
14
9
 
15
10
  interface UseRazorSplitOptions {
@@ -21,246 +16,10 @@ interface UseRazorSplitOptions {
21
16
  recordEdit: (input: RecordEditInput) => Promise<void>;
22
17
  domEditSaveTimestampRef: React.MutableRefObject<number>;
23
18
  reloadPreview: () => void;
24
- /**
25
- * Resync the in-memory SDK session after the server-side split write (the
26
- * split-element / split-gsap endpoints write the file directly, so the SDK's
27
- * linkedom doc is now stale). This reload is read-only; the split endpoint owns
28
- * the final on-disk bytes and history baseline. Every other server-side-write
29
- * timeline path (move / resize / delete / drop / visibility) also resyncs.
30
- */
31
19
  forceReloadSdkSession?: () => void;
32
20
  isRecordingRef?: React.RefObject<boolean>;
33
21
  }
34
22
 
35
- function generateSplitId(existingIds: string[], baseId: string): string {
36
- let newId = `${baseId}-split`;
37
- let suffix = 2;
38
- while (existingIds.includes(newId)) {
39
- newId = `${baseId}-split-${suffix++}`;
40
- }
41
- return newId;
42
- }
43
-
44
- async function splitHtmlElement(
45
- projectId: string,
46
- targetPath: string,
47
- patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
48
- splitTime: number,
49
- newId: string,
50
- elementStart: number,
51
- elementDuration: number,
52
- ): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
53
- const response = await fetch(
54
- `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
55
- {
56
- method: "POST",
57
- headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify({
59
- target: patchTarget,
60
- splitTime,
61
- newId,
62
- elementStart,
63
- elementDuration,
64
- }),
65
- },
66
- );
67
- if (!response.ok) throw new Error("Split request failed");
68
- const data = (await response.json()) as {
69
- ok: boolean;
70
- changed?: boolean;
71
- content?: string;
72
- version?: string;
73
- };
74
- const version = data.version ?? response.headers.get("etag");
75
- if (!version) throw new Error("Split response did not include a content version");
76
- return { ...data, version };
77
- }
78
-
79
- // fallow-ignore-next-line complexity
80
- async function splitGsapAnimations(
81
- projectId: string,
82
- targetPath: string,
83
- originalId: string,
84
- newId: string,
85
- splitTime: number,
86
- elementStart: number,
87
- elementDuration: number,
88
- ): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
89
- const response = await fetch(
90
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
91
- {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json" },
94
- body: JSON.stringify({
95
- type: "split-animations",
96
- originalId,
97
- newId,
98
- splitTime,
99
- elementStart,
100
- elementDuration,
101
- }),
102
- },
103
- );
104
- if (!response.ok) {
105
- const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
106
- if (errorBody?.error === "no GSAP script found in file") {
107
- return { content: null };
108
- }
109
- throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
110
- }
111
- const data = (await response.json()) as {
112
- ok?: boolean;
113
- after?: string;
114
- version?: string;
115
- skippedSelectors?: string[];
116
- };
117
- return {
118
- content: data.ok && data.after ? data.after : null,
119
- version: data.version ?? response.headers.get("etag") ?? undefined,
120
- skippedSelectors: data.skippedSelectors,
121
- };
122
- }
123
-
124
- function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
125
- const original = originals.get(path);
126
- if (original === undefined) {
127
- throw new Error(`Missing original contents for ${path}`);
128
- }
129
- return original;
130
- }
131
-
132
- async function restoreFilesToOriginal(
133
- originals: ReadonlyMap<string, string>,
134
- snapshots: ReadonlyMap<string, { before: string; after: string }>,
135
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
136
- ): Promise<void> {
137
- for (const [path, snapshot] of snapshots) {
138
- await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
139
- }
140
- }
141
-
142
- async function readOriginalFiles(
143
- pid: string,
144
- elements: TimelineElement[],
145
- activeCompPath: string | null,
146
- ): Promise<Map<string, string>> {
147
- const originals = new Map<string, string>();
148
- for (const element of elements) {
149
- const path = element.sourceFile || activeCompPath || "index.html";
150
- if (!originals.has(path)) {
151
- originals.set(path, await readFileContent(pid, path));
152
- }
153
- }
154
- return originals;
155
- }
156
-
157
- async function splitElementsAtTime(
158
- pid: string,
159
- elements: TimelineElement[],
160
- splitTime: number,
161
- activeCompPath: string | null,
162
- originals: ReadonlyMap<string, string>,
163
- snapshots: Map<string, { before: string; after: string }>,
164
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
165
- observeProjectFileVersion?: (path: string, version: string | null) => void,
166
- ): Promise<number> {
167
- let count = 0;
168
- for (const element of elements) {
169
- const result = await executeSplit(
170
- pid,
171
- element,
172
- splitTime,
173
- activeCompPath,
174
- writeProjectFile,
175
- observeProjectFileVersion,
176
- );
177
- if (!result.changed) continue;
178
- snapshots.set(result.targetPath, {
179
- before: getOriginalContent(originals, result.targetPath),
180
- after: result.patchedContent,
181
- });
182
- await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
183
- count++;
184
- }
185
- return count;
186
- }
187
-
188
- // fallow-ignore-next-line complexity
189
- async function executeSplit(
190
- pid: string,
191
- element: TimelineElement,
192
- splitTime: number,
193
- activeCompPath: string | null,
194
- writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
195
- observeProjectFileVersion?: (path: string, version: string | null) => void,
196
- ): Promise<{
197
- targetPath: string;
198
- originalContent: string;
199
- patchedContent: string;
200
- changed: boolean;
201
- skippedSelectors?: string[];
202
- }> {
203
- const patchTarget = buildPatchTarget(element);
204
- if (!patchTarget) throw new Error("Clip is missing a patchable target.");
205
-
206
- const targetPath = element.sourceFile || activeCompPath || "index.html";
207
- const originalContent = await readFileContent(pid, targetPath);
208
- const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
209
-
210
- // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
211
- // `start` and the incoming `splitTime` are offset by the host's master start
212
- // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
213
- // authored in LOCAL time. Rebase both onto local time before the server patches
214
- // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
215
- // Root-level clips (no expandedParentStart) are already local, so pass through.
216
- const basis = element.expandedParentStart;
217
- const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
218
- const localElementStart = basis === undefined ? element.start : element.start - basis;
219
-
220
- const splitResult = await splitHtmlElement(
221
- pid,
222
- targetPath,
223
- patchTarget,
224
- localSplitTime,
225
- newId,
226
- localElementStart,
227
- element.duration,
228
- );
229
- if (!splitResult.ok) throw new Error("Failed to split clip.");
230
- if (!splitResult.changed) {
231
- return { targetPath, originalContent, patchedContent: originalContent, changed: false };
232
- }
233
- observeProjectFileVersion?.(targetPath, splitResult.version);
234
-
235
- let patchedContent =
236
- typeof splitResult.content === "string" ? splitResult.content : originalContent;
237
-
238
- let skippedSelectors: string[] | undefined;
239
- if (element.domId) {
240
- try {
241
- const gsapResult = await splitGsapAnimations(
242
- pid,
243
- targetPath,
244
- element.domId,
245
- newId,
246
- localSplitTime,
247
- localElementStart,
248
- element.duration,
249
- );
250
- if (gsapResult.content) patchedContent = gsapResult.content;
251
- if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
252
- if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
253
- } catch (gsapError) {
254
- // GSAP mutation failed — the HTML split already wrote to disk.
255
- // Restore the original content to avoid a corrupt half-split state.
256
- await writeProjectFile(targetPath, originalContent, patchedContent);
257
- throw gsapError;
258
- }
259
- }
260
-
261
- return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
262
- }
263
-
264
23
  export function useRazorSplit({
265
24
  projectId,
266
25
  activeCompPath,
@@ -276,140 +35,109 @@ export function useRazorSplit({
276
35
  const projectIdRef = useRef(projectId);
277
36
  projectIdRef.current = projectId;
278
37
 
38
+ const synchronize = useCallback(() => {
39
+ let failure: unknown;
40
+ try {
41
+ forceReloadSdkSession?.();
42
+ } catch (error) {
43
+ failure = error;
44
+ }
45
+ try {
46
+ reloadPreview();
47
+ } catch (error) {
48
+ failure ??= error;
49
+ }
50
+ if (failure) throw failure;
51
+ }, [forceReloadSdkSession, reloadPreview]);
52
+
53
+ const runCut = useCallback(
54
+ async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
55
+ const pid = projectIdRef.current;
56
+ if (!pid || elements.length === 0) return;
57
+ const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
58
+ const requestedCount = intents.reduce((count, file) => count + file.targets.length, 0);
59
+ const label =
60
+ mode === "single"
61
+ ? "Split timeline clip"
62
+ : `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
63
+
64
+ // Server writes arrive through the watcher before React can refresh. Keep
65
+ // the existing short self-write window active for this owned transaction.
66
+ domEditSaveTimestampRef.current = Date.now();
67
+ const result = await runAtomicCutTransaction({
68
+ projectId: pid,
69
+ intents,
70
+ label,
71
+ writeProjectFile,
72
+ recordEdit,
73
+ observeProjectFileVersion,
74
+ synchronize,
75
+ });
76
+ trackStudioRazorSplit({ mode, count: result.splitCount });
77
+ if (result.syncFailed) {
78
+ showToast(
79
+ "Cut was saved, but Studio could not refresh it. Reload the preview to resynchronize.",
80
+ "error",
81
+ );
82
+ }
83
+ if (result.skippedSelectors.length > 0) {
84
+ showToast(
85
+ `Some animations use non-ID selectors (${result.skippedSelectors.join(", ")}) and were not retargeted`,
86
+ "info",
87
+ );
88
+ }
89
+ return result;
90
+ },
91
+ [
92
+ activeCompPath,
93
+ domEditSaveTimestampRef,
94
+ observeProjectFileVersion,
95
+ recordEdit,
96
+ showToast,
97
+ synchronize,
98
+ writeProjectFile,
99
+ ],
100
+ );
101
+
279
102
  const handleRazorSplit = useCallback(
280
- // fallow-ignore-next-line complexity
281
103
  async (element: TimelineElement, splitTime: number) => {
282
104
  if (isRecordingRef?.current) {
283
105
  showToast("Cannot edit timeline while recording", "error");
284
106
  return;
285
107
  }
286
-
287
- const pid = projectIdRef.current;
288
- if (!pid || !canSplitElementAt(element, splitTime)) return;
289
-
108
+ if (!canSplitElementAt(element, splitTime)) return;
290
109
  try {
291
- const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
292
- await executeSplit(
293
- pid,
294
- element,
295
- splitTime,
296
- activeCompPath,
297
- writeProjectFile,
298
- observeProjectFileVersion,
299
- );
300
-
301
- if (!changed) {
302
- showToast("Failed to split clip — playhead may be outside the clip", "error");
303
- return;
304
- }
305
-
306
- domEditSaveTimestampRef.current = Date.now();
307
- await saveProjectFilesWithHistory({
308
- projectId: pid,
309
- label: "Split timeline clip",
310
- kind: "timeline",
311
- files: { [targetPath]: patchedContent },
312
- readFile: async () => originalContent,
313
- writeFile: writeProjectFile,
314
- recordEdit,
315
- });
316
-
317
- // Server writes bypass the SDK session, so reopen it before refreshing
318
- // the preview. The split response already owns the final persisted bytes.
319
- forceReloadSdkSession?.();
320
- reloadPreview();
321
- trackStudioRazorSplit({ mode: "single", count: 1 });
110
+ const result = await runCut([element], splitTime, "single");
111
+ if (!result) return;
112
+ if (result.syncFailed) return;
322
113
  showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
323
- if (skippedSelectors?.length) {
324
- showToast(
325
- `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
326
- "info",
327
- );
328
- }
329
114
  } catch (error) {
330
115
  const message = error instanceof Error ? error.message : "Failed to split timeline clip";
331
116
  showToast(message, "error");
332
117
  }
333
118
  },
334
- [
335
- activeCompPath,
336
- recordEdit,
337
- showToast,
338
- writeProjectFile,
339
- observeProjectFileVersion,
340
- domEditSaveTimestampRef,
341
- reloadPreview,
342
- forceReloadSdkSession,
343
- isRecordingRef,
344
- ],
119
+ [isRecordingRef, runCut, showToast],
345
120
  );
346
121
 
347
122
  const handleRazorSplitAll = useCallback(
348
- // fallow-ignore-next-line complexity
349
123
  async (splitTime: number) => {
350
124
  if (isRecordingRef?.current) {
351
125
  showToast("Cannot edit timeline while recording", "error");
352
126
  return;
353
127
  }
354
-
355
- const pid = projectIdRef.current;
356
- if (!pid) return;
357
- const { elements } = usePlayerStore.getState();
358
- const splittable = selectSplittableElements(elements, splitTime);
128
+ const splittable = selectSplittableElements(usePlayerStore.getState().elements, splitTime);
359
129
  if (splittable.length === 0) return;
360
-
361
- let originals = new Map<string, string>();
362
- const finalSnapshots = new Map<string, { before: string; after: string }>();
363
130
  try {
364
- originals = await readOriginalFiles(pid, splittable, activeCompPath);
365
- const splitCount = await splitElementsAtTime(
366
- pid,
367
- splittable,
368
- splitTime,
369
- activeCompPath,
370
- originals,
371
- finalSnapshots,
372
- writeProjectFile,
373
- observeProjectFileVersion,
374
- );
375
- if (splitCount === 0) return;
376
-
377
- domEditSaveTimestampRef.current = Date.now();
378
- await recordEdit({
379
- label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
380
- kind: "timeline",
381
- files: Object.fromEntries(finalSnapshots),
382
- });
383
-
384
- // Resync the stale SDK doc after the batched server write (see the
385
- // single-split path above for why this precedes the reload).
386
- forceReloadSdkSession?.();
387
- reloadPreview();
388
- trackStudioRazorSplit({ mode: "all", count: splitCount });
389
- showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
131
+ const result = await runCut(splittable, splitTime, "all");
132
+ if (!result) return;
133
+ if (result.syncFailed) return;
134
+ showToast(`Split ${result.splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
390
135
  } catch (error) {
391
- // Best-effort rollback — a failing restore write must not swallow the
392
- // original error's toast, which is what tells the user the split failed.
393
- try {
394
- await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
395
- } catch {
396
- /* leave disk as-is; the original failure is reported below */
397
- }
398
136
  const message = error instanceof Error ? error.message : "Failed to split clips";
399
137
  showToast(message, "error");
400
138
  }
401
139
  },
402
- [
403
- activeCompPath,
404
- recordEdit,
405
- showToast,
406
- writeProjectFile,
407
- observeProjectFileVersion,
408
- domEditSaveTimestampRef,
409
- reloadPreview,
410
- forceReloadSdkSession,
411
- isRecordingRef,
412
- ],
140
+ [isRecordingRef, runCut, showToast],
413
141
  );
414
142
 
415
143
  return { handleRazorSplit, handleRazorSplitAll };
@@ -0,0 +1,15 @@
1
+ import { useCallback } from "react";
2
+ import { usePlayerStore } from "../player";
3
+
4
+ type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
5
+
6
+ export function useTimelineAddAtPlayhead(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
7
+ const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
8
+ return {
9
+ addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
10
+ addCompositionAtPlayhead: useCallback(
11
+ (path: string) => addComposition(path, placement()),
12
+ [addComposition],
13
+ ),
14
+ };
15
+ }
@@ -19,19 +19,22 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
19
19
  import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
20
20
  import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
21
21
  import { readFileContent } from "./timelineTimingSync";
22
+ import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
23
+ import { usePlayerStore } from "../player";
22
24
 
23
25
  interface UseTimelineAssetDropOpsOptions {
24
26
  projectIdRef: MutableRefObject<string | null>;
25
27
  activeCompPath: string | null;
26
28
  timelineElements: TimelineElement[];
27
29
  showToast: (message: string, tone?: "error" | "info") => void;
28
- writeProjectFile: (path: string, content: string) => Promise<void>;
30
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
29
31
  recordEdit: (input: RecordEditInput) => Promise<void>;
30
32
  domEditSaveTimestampRef: MutableRefObject<number>;
31
33
  reloadPreview: () => void;
32
34
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
33
35
  isRecordingRef?: RefObject<boolean>;
34
36
  forceReloadSdkSession?: () => void;
37
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
35
38
  }
36
39
 
37
40
  export function useTimelineAssetDropOps({
@@ -46,6 +49,7 @@ export function useTimelineAssetDropOps({
46
49
  uploadProjectFiles,
47
50
  isRecordingRef,
48
51
  forceReloadSdkSession,
52
+ observeProjectFileVersion,
49
53
  }: UseTimelineAssetDropOpsOptions) {
50
54
  // fallow-ignore-next-line complexity
51
55
  const handleTimelineAssetDrop = useCallback(
@@ -171,5 +175,51 @@ export function useTimelineAssetDropOps({
171
175
  [handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
172
176
  );
173
177
 
174
- return { handleTimelineAssetDrop, handleTimelineFileDrop };
178
+ const handleTimelineCompositionDrop = useCallback(
179
+ async (sourcePath: string, placement: Pick<TimelineElement, "start" | "track">) => {
180
+ if (isRecordingRef?.current) {
181
+ showToast("Cannot edit timeline while recording", "error");
182
+ return;
183
+ }
184
+ const pid = projectIdRef.current;
185
+ if (!pid) throw new Error("No active project");
186
+ const targetPath = activeCompPath || "index.html";
187
+ try {
188
+ await commitTimelineCompositionInsertion({
189
+ projectId: pid,
190
+ targetPath,
191
+ sourcePath,
192
+ start: placement.start,
193
+ track: placement.track,
194
+ writeFile: writeProjectFile,
195
+ recordEdit,
196
+ observeVersion: observeProjectFileVersion,
197
+ selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
198
+ resync: forceReloadSdkSession,
199
+ refresh: reloadPreview,
200
+ });
201
+ domEditSaveTimestampRef.current = Date.now();
202
+ showToast("Composition added to the timeline.", "info");
203
+ } catch (error) {
204
+ showToast(
205
+ error instanceof Error ? error.message : "Failed to add composition to timeline",
206
+ "error",
207
+ );
208
+ }
209
+ },
210
+ [
211
+ activeCompPath,
212
+ domEditSaveTimestampRef,
213
+ forceReloadSdkSession,
214
+ isRecordingRef,
215
+ observeProjectFileVersion,
216
+ projectIdRef,
217
+ recordEdit,
218
+ reloadPreview,
219
+ showToast,
220
+ writeProjectFile,
221
+ ],
222
+ );
223
+
224
+ return { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop };
175
225
  }
@@ -12,6 +12,7 @@ import {
12
12
  applyTimelineStackingReorder,
13
13
  buildPatchTarget,
14
14
  patchIframeDomTiming,
15
+ playbackStartAttributeForElement,
15
16
  persistTimelineEdit,
16
17
  formatTimelineAttributeNumber,
17
18
  extendRootDurationIfNeeded,
@@ -263,10 +264,7 @@ export function useTimelineEditing({
263
264
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
264
265
  ];
265
266
  if (updates.playbackStart != null) {
266
- const liveAttr =
267
- element.playbackStartAttr === "playback-start"
268
- ? "data-playback-start"
269
- : "data-media-start";
267
+ const liveAttr = playbackStartAttributeForElement(element);
270
268
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
271
269
  }
272
270
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
@@ -475,19 +473,21 @@ export function useTimelineEditing({
475
473
  ],
476
474
  );
477
475
 
478
- const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
479
- projectIdRef,
480
- activeCompPath,
481
- timelineElements,
482
- showToast,
483
- writeProjectFile,
484
- recordEdit,
485
- domEditSaveTimestampRef,
486
- reloadPreview,
487
- uploadProjectFiles,
488
- isRecordingRef,
489
- forceReloadSdkSession,
490
- });
476
+ const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
477
+ useTimelineAssetDropOps({
478
+ projectIdRef,
479
+ activeCompPath,
480
+ timelineElements,
481
+ showToast,
482
+ writeProjectFile,
483
+ recordEdit,
484
+ domEditSaveTimestampRef,
485
+ reloadPreview,
486
+ uploadProjectFiles,
487
+ isRecordingRef,
488
+ forceReloadSdkSession,
489
+ observeProjectFileVersion,
490
+ });
491
491
 
492
492
  const handleBlockedTimelineEdit = useCallback(
493
493
  (_element: TimelineElement) => {
@@ -509,6 +509,7 @@ export function useTimelineEditing({
509
509
  domEditSaveTimestampRef,
510
510
  reloadPreview,
511
511
  isRecordingRef,
512
+ forceReloadSdkSession,
512
513
  });
513
514
 
514
515
  return {
@@ -522,6 +523,7 @@ export function useTimelineEditing({
522
523
  handleRazorSplitAll,
523
524
  handleTimelineAssetDrop,
524
525
  handleTimelineFileDrop,
526
+ handleTimelineCompositionDrop,
525
527
  handleBlockedTimelineEdit,
526
528
  ...groupEditing,
527
529
  };
@@ -12,6 +12,7 @@ import {
12
12
  extendRootDurationIfNeeded,
13
13
  formatTimelineAttributeNumber,
14
14
  patchIframeDomTiming,
15
+ playbackStartAttributeForElement,
15
16
  persistTimelineBatchEdit,
16
17
  type PersistTimelineBatchChange,
17
18
  type RecordEditInput,
@@ -242,9 +243,10 @@ export function useTimelineGroupEditing({
242
243
  // duration change), so nothing timing-related changed — the batch only
243
244
  // rewrites data-track-index, which the renderer never reads (documented
244
245
  // in core runtime/timeline.ts; track is a studio lane concept). The live
245
- // DOM patch above + the gesture owner's optimistic store update fully
246
- // cover the UI, so after the persist there is nothing to GSAP-shift and
247
- // nothing for the preview to recompute: skip the fallback below entirely.
246
+ // DOM patch above + the gesture owner's optimistic store update cover the
247
+ // in-flight UI; after the complete lane + z transaction, that owner
248
+ // refreshes the preview so its runtime manifest converges to disk. There
249
+ // is still nothing to GSAP-shift here, so skip this fallback entirely.
248
250
  // Running it anyway is what made the mirrored z-order lane move blink —
249
251
  // a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
250
252
  // used to full-reload the iframe when there was no script to soft-swap
@@ -348,10 +350,7 @@ export function useTimelineGroupEditing({
348
350
  ["data-duration", formatTimelineAttributeNumber(change.duration)],
349
351
  ];
350
352
  if (change.playbackStart != null) {
351
- const liveAttr =
352
- change.element.playbackStartAttr === "playback-start"
353
- ? "data-playback-start"
354
- : "data-media-start";
353
+ const liveAttr = playbackStartAttributeForElement(change.element);
355
354
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
356
355
  }
357
356
  patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);