@hyperframes/studio 0.8.100 → 0.8.101

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 (39) hide show
  1. package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
  2. package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
  3. package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
  4. package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +91 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +403 -226
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/hooks/appHotkeysDispatch.ts +1 -7
  20. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  21. package/src/hooks/useAppHotkeys.ts +18 -45
  22. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  23. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  24. package/src/hooks/useEditHistoryActions.ts +36 -8
  25. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  26. package/src/hooks/usePersistentEditHistory.ts +101 -19
  27. package/src/hooks/usePreviewPersistence.ts +25 -2
  28. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  29. package/src/index.ts +4 -0
  30. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  31. package/src/player/components/timelineDragDrop.ts +29 -8
  32. package/src/player/components/useTimelineProviderState.tsx +1 -0
  33. package/src/utils/domEditSaveQueue.ts +7 -2
  34. package/src/utils/gsapSoftReload.ts +1 -1
  35. package/src/utils/gsapUndoRestore.test.ts +46 -0
  36. package/src/utils/gsapUndoRestore.ts +166 -91
  37. package/src/utils/studioPendingEdits.ts +14 -5
  38. package/src/utils/studioUiPreferences.ts +8 -6
  39. package/dist/chunk-U3EE4ACX.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
+ import type { RestoreFiles } from "../utils/gsapUndoRestore";
4
5
 
5
6
  interface RecordEditInput {
6
7
  label: string;
@@ -18,11 +19,6 @@ export interface UsePersistentEditHistoryOptions {
18
19
  projectId: string | null;
19
20
  }
20
21
 
21
- interface ApplyRestoredFile {
22
- previous: string;
23
- restored: string;
24
- }
25
-
26
22
  interface ApplyResult {
27
23
  ok: boolean;
28
24
  /** content-mismatch: `paths` changed after the step's entry. failed: `message` says why. */
@@ -30,7 +26,8 @@ interface ApplyResult {
30
26
  message?: string;
31
27
  label?: string;
32
28
  paths?: string[];
33
- files?: Record<string, ApplyRestoredFile>;
29
+ undoes?: string;
30
+ files?: RestoreFiles;
34
31
  }
35
32
 
36
33
  interface NextStep {
@@ -47,6 +44,69 @@ interface HistoryView {
47
44
  }
48
45
 
49
46
  const EMPTY: HistoryView = { entries: [], back: null, forward: null };
47
+ /** ponytail: the newest entries' content is kept; older ones step through the server only. */
48
+ const OWN_ENTRIES_KEPT = 100;
49
+
50
+ type OwnFiles = Record<string, { before: string; after: string }>;
51
+
52
+ function createOwnHistory() {
53
+ const own = new Map<string, OwnFiles>();
54
+ let next: Record<"undo" | "redo", NextStep | null> | null = null;
55
+ let changes = 0;
56
+ const remember = (id: string, files: OwnFiles) => {
57
+ const known = own.get(id) ?? {};
58
+ for (const [path, { before, after }] of Object.entries(files)) {
59
+ known[path] = { before: known[path]?.before ?? before, after };
60
+ }
61
+ own.delete(id);
62
+ own.set(id, known);
63
+ if (own.size > OWN_ENTRIES_KEPT) own.delete(own.keys().next().value!);
64
+ };
65
+ return {
66
+ remember,
67
+ overtake: () => {
68
+ changes += 1;
69
+ next = null;
70
+ return changes;
71
+ },
72
+ offered: (seen: number, view: HistoryView) => {
73
+ if (seen === changes) next = { undo: view.back, redo: view.forward };
74
+ },
75
+ changes: () => changes,
76
+ stepped: (entry: { id: string; undoes?: string }) => {
77
+ const undone = entry.undoes ? own.get(entry.undoes) : undefined;
78
+ if (!undone) return;
79
+ const swapped = Object.entries(undone).map(([path, f]) => [
80
+ path,
81
+ { before: f.after, after: f.before },
82
+ ]);
83
+ remember(entry.id, Object.fromEntries(swapped));
84
+ },
85
+ afterOf: (id: string | undefined): Record<string, string> =>
86
+ Object.fromEntries(Object.entries((id && own.get(id)) || {}).map(([p, f]) => [p, f.after])),
87
+ predict: (direction: "undo" | "redo"): { id: string; files: RestoreFiles } | null => {
88
+ const step = next?.[direction];
89
+ const files = step ? own.get(step.id) : undefined;
90
+ const paths = files ? Object.keys(files) : [];
91
+ if (
92
+ !files ||
93
+ paths.length !== step!.paths.length ||
94
+ !paths.every((p) => step!.paths.includes(p))
95
+ )
96
+ return null;
97
+ const restore = paths.map((path) => [
98
+ path,
99
+ { previous: files[path]!.after, restored: files[path]!.before },
100
+ ]);
101
+ return { id: step!.id, files: Object.fromEntries(restore) };
102
+ },
103
+ clear: () => {
104
+ own.clear();
105
+ next = null;
106
+ changes += 1;
107
+ },
108
+ };
109
+ }
50
110
  const DEFAULT_COALESCE_MS = 300;
51
111
 
52
112
  function historyUrl(projectId: string, path = ""): string {
@@ -71,12 +131,13 @@ async function post(
71
131
  return { ok: false, status: response.status, error: reply?.error ?? `HTTP ${response.status}` };
72
132
  }
73
133
 
74
- /** Whether a drag's claim is held open; a failed one is logged (its write lands as an outside change; 404: none). */
75
- function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string): boolean {
76
- if (reply.ok) return Boolean((reply.body as { claimed: { id: string } | null } | null)?.claimed);
134
+ /** The entry id a claim took, or null; a failed one is logged (its write lands as an outside change; 404: none). */
135
+ function claimHeld(reply: Awaited<ReturnType<typeof post>>, label: string): string | null {
136
+ if (reply.ok)
137
+ return (reply.body as { claimed: { id: string } | null } | null)?.claimed?.id ?? null;
77
138
  if (reply.status !== 404)
78
139
  console.error(`"${label}" was not recorded as your edit: ${reply.error}`);
79
- return false;
140
+ return null;
80
141
  }
81
142
 
82
143
  async function overwroteVersions(files: RecordEditInput["files"]): Promise<Record<string, string>> {
@@ -106,7 +167,7 @@ async function restoredFiles(
106
167
  paths: readonly string[],
107
168
  previous: Record<string, string> | null,
108
169
  readFile: (path: string) => Promise<string>,
109
- ): Promise<Record<string, ApplyRestoredFile> | undefined> {
170
+ ): Promise<RestoreFiles | undefined> {
110
171
  if (!previous || paths.some((path) => !(path in previous))) return undefined;
111
172
  const restored = await readAll(paths, readFile);
112
173
  if (!restored) return undefined;
@@ -128,36 +189,43 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
128
189
  const heldClaimRef = useRef<{ paths: string[]; at: number } | null>(null);
129
190
  const projectIdRef = useRef(projectId);
130
191
  projectIdRef.current = projectId;
192
+ const [own] = useState(createOwnHistory);
131
193
 
132
194
  const refresh = useCallback(async () => {
133
195
  if (!projectId) return;
196
+ const seen = own.changes();
134
197
  const response = await fetch(historyUrl(projectId)).catch(() => null);
135
198
  const next = response?.ok ? ((await response.json()) as HistoryView) : EMPTY;
136
- if (projectIdRef.current === projectId) setView(next);
137
- }, [projectId]);
199
+ if (projectIdRef.current !== projectId) return;
200
+ setView(next);
201
+ own.offered(seen, next);
202
+ }, [projectId, own]);
138
203
 
139
204
  useEffect(() => {
140
205
  setView(EMPTY);
141
206
  setLoaded(false);
142
207
  heldClaimRef.current = null;
208
+ own.clear();
143
209
  void refresh().finally(() => setLoaded(true));
144
- }, [refresh]);
210
+ }, [refresh, own]);
145
211
 
146
212
  const recordEdit = useCallback(
147
213
  async ({ label, coalesceKey, coalesceMs, files }: RecordEditInput) => {
148
214
  if (!projectId) return;
149
215
  const paths = Object.keys(files);
216
+ own.overtake();
150
217
  const reply = await post(historyUrl(projectId, "/claim"), {
151
218
  label,
152
219
  paths,
153
220
  overwrote: await overwroteVersions(files),
154
221
  ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
155
222
  });
156
- heldClaimRef.current =
157
- claimHeld(reply, label) && coalesceKey ? { paths, at: Date.now() } : null;
223
+ const claimed = claimHeld(reply, label);
224
+ if (claimed) own.remember(claimed, files);
225
+ heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
158
226
  void refresh();
159
227
  },
160
- [projectId, refresh],
228
+ [projectId, refresh, own],
161
229
  );
162
230
 
163
231
  const step = useCallback(
@@ -165,6 +233,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
165
233
  if (!projectId) return { ok: false, reason: "empty" };
166
234
  const next = direction === "undo" ? view.back : view.forward;
167
235
  const paths = [...new Set([...(next?.paths ?? []), ...(heldClaimRef.current?.paths ?? [])])];
236
+ own.overtake();
168
237
  const run = async (): Promise<ApplyResult> => {
169
238
  const previous = await readAll(paths, callbacks.readFile);
170
239
  const posted = await post(
@@ -183,19 +252,30 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
183
252
  return { ok: false, reason: "content-mismatch", paths: files };
184
253
  }
185
254
  if (!reply.entry) return { ok: false, reason: "empty" };
255
+ own.stepped(reply.entry);
186
256
  const changed = reply.entry.files.map((file) => file.path);
187
257
  return {
188
258
  ok: true,
189
259
  label: reply.entry.label,
260
+ undoes: reply.entry.undoes,
190
261
  paths: changed,
191
- files: await restoredFiles(changed, previous, callbacks.readFile),
262
+ files: await restoredFiles(
263
+ changed,
264
+ { ...own.afterOf(reply.entry.undoes), ...previous },
265
+ callbacks.readFile,
266
+ ),
192
267
  };
193
268
  };
194
269
  return callbacks.serialize ? callbacks.serialize(paths, run) : run();
195
270
  },
196
- [projectId, view, refresh],
271
+ [projectId, view, refresh, own],
197
272
  );
198
273
 
274
+ const noteOutsideChange = useCallback(() => {
275
+ own.overtake();
276
+ void refresh();
277
+ }, [own, refresh]);
278
+
199
279
  const undo = useCallback((callbacks: ApplyCallbacks) => step("undo", callbacks), [step]);
200
280
  const redo = useCallback((callbacks: ApplyCallbacks) => step("redo", callbacks), [step]);
201
281
 
@@ -220,5 +300,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
220
300
  recordEdit,
221
301
  undo,
222
302
  redo,
303
+ predict: own.predict,
304
+ noteOutsideChange,
223
305
  };
224
306
  }
@@ -8,17 +8,22 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
8
  import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
9
9
  import {
10
10
  flushStudioPendingEdits,
11
+ hasStudioPendingEdits,
11
12
  type StudioPendingEditsDrainResult,
12
13
  } from "../utils/studioPendingEdits";
13
14
  import { trackStudioEvent } from "../utils/studioTelemetry";
14
- import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
15
+ import {
16
+ applyUndoRestoreToPreview,
17
+ showRestoreInPlace,
18
+ type RestoreFiles,
19
+ } from "../utils/gsapUndoRestore";
15
20
  import { usePlayerStore } from "../player";
16
21
  import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
17
22
 
18
23
  /** The restore payload the undo/redo preview-sync consumes (from the history store). */
19
24
  interface HistoryPreviewRestore {
20
25
  paths?: string[];
21
- files?: Record<string, UndoRestoreFile>;
26
+ files?: RestoreFiles;
22
27
  }
23
28
 
24
29
  // ── Types ──
@@ -217,6 +222,23 @@ export function usePreviewPersistence({
217
222
  [previewIframeRef, activeCompPathRef, reloadPreview],
218
223
  );
219
224
 
225
+ // A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
226
+ // synchronous, and a pending save would land under it.
227
+ const showHistoryRestoreNow = useCallback(
228
+ (files: RestoreFiles): (() => void) | null => {
229
+ if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
230
+ const iframe = previewIframeRef.current;
231
+ const now = () => usePlayerStore.getState().currentTime;
232
+ const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
233
+ if (!putBack) return null;
234
+ return () => {
235
+ if (putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
236
+ else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
237
+ };
238
+ },
239
+ [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
240
+ );
241
+
220
242
  // ── Migrate legacy studio-motion.json ──
221
243
  // Projects that used the old JSON-file approach may still have a populated
222
244
  // `.hyperframes/studio-motion.json`. The studio no longer reads from it, but
@@ -239,5 +261,6 @@ export function usePreviewPersistence({
239
261
  applyCurrentStudioManualEditsToPreview,
240
262
  applyStudioManualEditsToPreview,
241
263
  syncHistoryPreviewAfterApply,
264
+ showHistoryRestoreNow,
242
265
  };
243
266
  }
@@ -36,6 +36,7 @@ interface UseStudioExternalFileChangesOptions {
36
36
  previewPersistence: PreviewPersistence;
37
37
  pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
38
38
  reloadPreview: () => void;
39
+ onOutsideChange?: () => void;
39
40
  }
40
41
 
41
42
  /** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
@@ -47,14 +48,17 @@ export function useStudioExternalFileChanges({
47
48
  previewPersistence,
48
49
  pendingTimelineEditPathRef,
49
50
  reloadPreview,
51
+ onOutsideChange,
50
52
  }: UseStudioExternalFileChangesOptions) {
51
53
  const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
52
54
  const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
53
55
  const bumpThumbnailRevisions = usePlayerStore((state) => state.bumpThumbnailRevisions);
54
56
  const onAcceptedPersistedFileChange = useCallback(
55
- (_path: string, affectedCompositions: readonly string[] | null) =>
56
- bumpThumbnailRevisions(affectedCompositions),
57
- [bumpThumbnailRevisions],
57
+ (_path: string, affectedCompositions: readonly string[] | null) => {
58
+ onOutsideChange?.();
59
+ bumpThumbnailRevisions(affectedCompositions);
60
+ },
61
+ [bumpThumbnailRevisions, onOutsideChange],
58
62
  );
59
63
  const drainPendingChanges = useCallback(async () => {
60
64
  const source = await flushPendingSourceSave();
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ export type { ShortcutHint, ShortcutSection } from "./player/components/studioSh
12
12
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
13
13
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
14
14
  export { useCompositionStack } from "./components/nle/useCompositionStack";
15
+ export { Dock } from "./components/dock/Dock";
16
+ export { useDockLayoutStore } from "./components/dock/dockLayoutStore";
17
+ export type { DockController } from "./components/dock/dockLayoutStore";
18
+ export type { PanelId } from "./components/dock/panelRegistry";
15
19
 
16
20
  // Player (preview, timeline, playback controls)
17
21
  export {
@@ -4,7 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
6
6
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
7
- import { usePlayerStore } from "../store/playerStore";
7
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
8
  import { createTimelineRowGeometry } from "./timelineLayout";
9
9
  import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop";
10
10
  import { getTimelineRowTop, TRACK_H } from "./timelineLayout";
@@ -39,10 +39,19 @@ function assetTransfer(payload: string): DropTransfer {
39
39
  };
40
40
  }
41
41
 
42
+ // One far-off clip per lane, so a drop keeps its pointer time instead of an empty lane's 0.
43
+ const FAR_CLIPS: TimelineElement[] = Array.from({ length: 100 }, (_, track) => ({
44
+ id: `far-${track}`,
45
+ tag: "div",
46
+ start: 900,
47
+ duration: 1,
48
+ track,
49
+ }));
50
+
42
51
  function renderHarness(
43
52
  onAssetDrop: Mock,
44
53
  sessionEpoch = 1,
45
- options: { onBlockDrop?: Mock; strict?: boolean } = {},
54
+ options: { onBlockDrop?: Mock; strict?: boolean; elements?: TimelineElement[] } = {},
46
55
  ) {
47
56
  const tracks = Array.from({ length: 100 }, (_, index) => index);
48
57
  const geometry = createTimelineRowGeometry(
@@ -60,6 +69,7 @@ function renderHarness(
60
69
  scrollRef: { current: scroll },
61
70
  ppsRef: { current: 40 },
62
71
  trackOrderRef: { current: tracks },
72
+ elementsRef: { current: options.elements ?? FAR_CLIPS },
63
73
  rowGeometryRef: { current: geometry },
64
74
  contentOrigin: 0,
65
75
  sessionEpoch: epoch,
@@ -149,8 +159,8 @@ describe("useTimelineAssetDrop", () => {
149
159
  });
150
160
 
151
161
  expect(onAssetDrop).toHaveBeenCalledTimes(1);
152
- // pps=40, clientX=400 -> 10s at the pointer, not the playhead.
153
- expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 10, track: 100 });
162
+ // The appended lane is empty, so the drop starts at 0.
163
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 0, track: 100 });
154
164
  expect(view.api.isDragOver).toBe(false);
155
165
  act(() => view.root.unmount());
156
166
  });
@@ -256,6 +266,52 @@ describe("useTimelineAssetDrop", () => {
256
266
  });
257
267
  });
258
268
 
269
+ describe("useTimelineAssetDrop start", () => {
270
+ function dropAt(elements: TimelineElement[], clientX: number) {
271
+ const onAssetDrop = vi.fn();
272
+ const view = renderHarness(onAssetDrop, 1, { elements });
273
+ const transfer = assetTransfer(JSON.stringify({ path: "assets/a.mp4" }));
274
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, clientX, ROW0_MID_Y)));
275
+ const preview = view.api.dropPreview;
276
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, clientX, ROW0_MID_Y)));
277
+ act(() => view.root.unmount());
278
+ return { preview, committed: onAssetDrop.mock.calls[0]?.[1] };
279
+ }
280
+
281
+ it("starts a drop on an empty timeline at 0, in the preview and the commit", () => {
282
+ // pps=40, clientX=80 -> 2s at the pointer.
283
+ const { preview, committed } = dropAt([], 80);
284
+ expect(preview).toEqual({ start: 0, track: 0 });
285
+ expect(committed).toEqual(preview);
286
+ });
287
+
288
+ it("starts a drop on an empty lane at 0 while other lanes hold clips", () => {
289
+ const { committed } = dropAt([{ id: "c", tag: "div", start: 0, duration: 5, track: 1 }], 80);
290
+ expect(committed).toEqual({ start: 0, track: 0 });
291
+ });
292
+
293
+ it("snaps a drop within TIMELINE_SNAP_PX of a clip edge onto that edge", () => {
294
+ const lane: TimelineElement[] = [{ id: "c", tag: "div", start: 0, duration: 5, track: 0 }];
295
+ // The clip ends at x=200; 6 px past it is 5.15s, inside the 8 px radius.
296
+ const { preview, committed } = dropAt(lane, 206);
297
+ expect(preview).toEqual({ start: 5, track: 0 });
298
+ expect(committed).toEqual(preview);
299
+ // 12 px past it is outside the radius and keeps the pointer time.
300
+ expect(dropAt(lane, 212).committed).toEqual({ start: 5.3, track: 0 });
301
+ });
302
+
303
+ it("keeps the pointer time with the magnet off, and still starts an empty lane at 0", () => {
304
+ usePlayerStore.getState().setTimelineSnapEnabled(false);
305
+ try {
306
+ const lane: TimelineElement[] = [{ id: "c", tag: "div", start: 0, duration: 5, track: 0 }];
307
+ expect(dropAt(lane, 206).committed).toEqual({ start: 5.15, track: 0 });
308
+ expect(dropAt([], 80).committed).toEqual({ start: 0, track: 0 });
309
+ } finally {
310
+ usePlayerStore.getState().setTimelineSnapEnabled(true);
311
+ }
312
+ });
313
+ });
314
+
259
315
  describe("resolveDropInsertRow", () => {
260
316
  const rows = [TRACK_H, TRACK_H, TRACK_H];
261
317
  it("stays on the row at the boundary between two rows", () => {
@@ -302,7 +358,7 @@ describe("useTimelineAssetDrop new-track drops", () => {
302
358
  expect(onAssetDrop).toHaveBeenCalledWith(
303
359
  "assets/a.png",
304
360
  expect.objectContaining({
305
- start: 10,
361
+ start: 0,
306
362
  insertRow: 0,
307
363
  trackOrder: Array.from({ length: 100 }, (_, index) => index),
308
364
  }),
@@ -10,6 +10,8 @@ import {
10
10
  type TimelineRowGeometry,
11
11
  } from "./timelineLayout";
12
12
  import { resolveInsertRow } from "./timelineCollision";
13
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
14
+ import { collectTimelineSnapTargets, snapTimelineTime, TIMELINE_SNAP_PX } from "./timelineSnapping";
13
15
  import type { TimelineDropCallbacks, TimelineDropPlacement } from "./timelineCallbacks";
14
16
  import {
15
17
  applyTimelineAutoScrollStep,
@@ -20,6 +22,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
20
22
  scrollRef: RefObject<HTMLDivElement | null>;
21
23
  ppsRef: RefObject<number>;
22
24
  trackOrderRef: RefObject<number[]>;
25
+ elementsRef: RefObject<readonly TimelineElement[]>;
23
26
  rowGeometryRef: RefObject<TimelineRowGeometry>;
24
27
  contentOrigin: number;
25
28
  sessionEpoch: number;
@@ -65,20 +68,36 @@ export function resolveDropInsertRow(
65
68
  return resolveInsertRow(rowFloat, trackCount);
66
69
  }
67
70
 
71
+ function alignDropStart(
72
+ placement: TimelineDropPlacement,
73
+ elements: readonly TimelineElement[],
74
+ pixelsPerSecond: number,
75
+ ): TimelineDropPlacement {
76
+ const laneHasClips =
77
+ placement.insertRow == null && elements.some((el) => el.track === placement.track);
78
+ if (!laneHasClips) return { ...placement, start: 0 };
79
+ if (!usePlayerStore.getState().timelineSnapEnabled) return placement;
80
+ const targets = collectTimelineSnapTargets({ elements, playheadTime: null, beatTimes: [] });
81
+ const threshold = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
82
+ return { ...placement, start: snapTimelineTime(placement.start, targets, threshold).time };
83
+ }
84
+
68
85
  function placeDrop(
69
86
  geometry: Parameters<typeof resolveTimelineAssetDrop>[0],
87
+ elements: readonly TimelineElement[],
70
88
  clientX: number,
71
89
  clientY: number,
72
90
  // Blocks and compositions are written by their own installers, which only take a track.
73
91
  canInsertTrack: boolean,
74
92
  ): TimelineDropPlacement {
75
93
  const placement = resolveTimelineAssetDrop(geometry, clientX, clientY);
76
- if (!canInsertTrack) return placement;
77
94
  const contentY = clientY - geometry.rectTop + geometry.scrollTop;
78
- const insertRow = resolveDropInsertRow(contentY, geometry.rowHeights, geometry.trackOrder.length);
79
- return insertRow == null
80
- ? placement
81
- : { ...placement, insertRow, trackOrder: geometry.trackOrder };
95
+ const insertRow = canInsertTrack
96
+ ? resolveDropInsertRow(contentY, geometry.rowHeights, geometry.trackOrder.length)
97
+ : null;
98
+ const aimed =
99
+ insertRow == null ? placement : { ...placement, insertRow, trackOrder: geometry.trackOrder };
100
+ return alignDropStart(aimed, elements, geometry.pixelsPerSecond);
82
101
  }
83
102
 
84
103
  function invokeDropCallback(callback: () => Promise<void> | void): void {
@@ -118,14 +137,15 @@ function applyTypedJsonDrop(
118
137
  }
119
138
 
120
139
  /**
121
- * Dropping an asset/file/block/composition onto the timeline places it on the row
122
- * and at the time it was dropped on; an asset moves to that row's nearest free time.
140
+ * Dropping an asset/file/block/composition places it on the row at 0 on an empty lane, else at
141
+ * the pointer snapped to a near clip edge; an asset moves to that row's nearest free time.
123
142
  * Supersedes the prior playhead decision (#2291); see useAddAssetAtPlayhead.
124
143
  */
125
144
  export function useTimelineAssetDrop({
126
145
  scrollRef,
127
146
  ppsRef,
128
147
  trackOrderRef,
148
+ elementsRef,
129
149
  rowGeometryRef,
130
150
  contentOrigin,
131
151
  onFileDrop,
@@ -197,12 +217,13 @@ export function useTimelineAssetDrop({
197
217
  rowHeights: rowGeometryRef.current.rowHeights,
198
218
  trackOrder: trackOrderRef.current,
199
219
  },
220
+ elementsRef.current,
200
221
  clientX,
201
222
  clientY,
202
223
  canInsertTrack,
203
224
  );
204
225
  },
205
- [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
226
+ [scrollRef, ppsRef, trackOrderRef, elementsRef, rowGeometryRef, contentOrigin],
206
227
  );
207
228
 
208
229
  const handleAssetDragOver = useCallback(
@@ -229,6 +229,7 @@ export function useTimelineProviderState({
229
229
  scrollRef,
230
230
  ppsRef,
231
231
  trackOrderRef,
232
+ elementsRef: timelineElementsRef,
232
233
  rowGeometryRef,
233
234
  contentOrigin,
234
235
  onFileDrop: pinnedOnFileDrop,
@@ -20,6 +20,7 @@ interface DomEditSaveQueueOptions {
20
20
  export interface DomEditSaveQueue {
21
21
  enqueue: <T>(save: () => Promise<T>) => Promise<T>;
22
22
  waitForIdle: () => Promise<DomEditSaveDrainResult>;
23
+ isIdle: () => boolean;
23
24
  reset: () => void;
24
25
  destroy: () => void;
25
26
  }
@@ -39,6 +40,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
39
40
  const failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
40
41
 
41
42
  let tail = Promise.resolve();
43
+ let queuedCount = 0;
42
44
  let consecutiveFailures = 0;
43
45
  let breakerOpen = false;
44
46
  let drainError: unknown = null;
@@ -81,6 +83,7 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
81
83
  return {
82
84
  enqueue(save) {
83
85
  if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
86
+ queuedCount += 1;
84
87
  const queued = tail
85
88
  .catch(() => undefined)
86
89
  .then(() => {
@@ -88,8 +91,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
88
91
  return run(save);
89
92
  });
90
93
  tail = queued.then(
91
- () => undefined,
92
- () => undefined,
94
+ () => void (queuedCount -= 1),
95
+ () => void (queuedCount -= 1),
93
96
  );
94
97
  return queued;
95
98
  },
@@ -103,6 +106,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
103
106
  return { status: "clean" };
104
107
  },
105
108
 
109
+ isIdle: () => queuedCount === 0,
110
+
106
111
  reset,
107
112
 
108
113
  destroy() {
@@ -91,7 +91,7 @@ function isGsapScript(text: string): boolean {
91
91
  );
92
92
  }
93
93
 
94
- export function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
94
+ export function findGsapScriptElements(doc: ParentNode): HTMLScriptElement[] {
95
95
  const results: HTMLScriptElement[] = [];
96
96
  const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
97
97
  for (const script of scripts) {
@@ -366,6 +366,52 @@ describe("applyUndoRestoreToPreview", () => {
366
366
  expect(meta.content).toBe(content);
367
367
  });
368
368
 
369
+ const SUB = "compositions/sub.html";
370
+ const sub = (style: string, rootAttrs = "") =>
371
+ `<template id="sub-template"><div data-hf-id="hf-root" id="sub" data-composition-id="sub"${rootAttrs}><div ${style} data-hf-id="hf-t" id="target"></div></div></template>`;
372
+ const host = (style: string) =>
373
+ `<div data-composition-file="${SUB}" data-hf-id="hf-host"><div data-hf-inner-root="true" data-hf-authored-id="sub" data-hf-id="hf-root"><div data-hf-id="hf-t" id="target" ${style}></div></div></div>`;
374
+
375
+ it("restores a sub-composition file in place, on every host that inlines it", () => {
376
+ const { iframe, doc } = buildLiveIframe(
377
+ host(`style="clip-path: inset(0px 40px 0px 0px);"`) +
378
+ host(`style="clip-path: inset(0px 40px 0px 0px);"`),
379
+ );
380
+ const reloadPreview = vi.fn();
381
+ const files = {
382
+ [SUB]: { previous: sub(`style="clip-path: inset(0px 40px 0px 0px)"`), restored: sub("") },
383
+ };
384
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
385
+ expect(reloadPreview).not.toHaveBeenCalled();
386
+ const targets = [...doc.querySelectorAll('[data-hf-id="hf-t"]')];
387
+ expect(targets.map((el) => el.getAttribute("style"))).toEqual([null, null]);
388
+ });
389
+
390
+ it("full-reloads a sub-composition restore that changes the file's own root", () => {
391
+ const { iframe } = buildLiveIframe(host(""));
392
+ const reloadPreview = vi.fn();
393
+ const files = { [SUB]: { previous: sub("", ' data-width="10"'), restored: sub("") } };
394
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
395
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
396
+ });
397
+
398
+ it("full-reloads a sub-composition restore whose file has a GSAP script", () => {
399
+ const { iframe, doc } = buildLiveIframe(host(`style="clip-path: inset(0px 40px 0px 0px);"`));
400
+ const reloadPreview = vi.fn();
401
+ const script = `<script>gsap.timeline().to("#target", { x: 10 });</script>`;
402
+ const scripted = (style: string) =>
403
+ sub(style).replace("</div></template>", `${script}</div></template>`);
404
+ const files = {
405
+ [SUB]: {
406
+ previous: scripted(`style="clip-path: inset(0px 40px 0px 0px)"`),
407
+ restored: scripted(""),
408
+ },
409
+ };
410
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
411
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
412
+ expect(doc.querySelector('[data-hf-id="hf-t"]')?.getAttribute("style")).toContain("clip-path");
413
+ });
414
+
369
415
  it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
370
416
  const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
371
417
  const reloadPreview = vi.fn();