@hyperframes/studio 0.7.46 → 0.7.48

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 (110) hide show
  1. package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -0,0 +1,963 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { openComposition } from "@hyperframes/sdk";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { usePlayerStore, type TimelineElement } from "../player";
8
+ import { useElementLifecycleOps } from "./useElementLifecycleOps";
9
+ import { useTimelineEditing } from "./useTimelineEditing";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ type ZIndexEntry = {
14
+ element: HTMLElement;
15
+ zIndex: number;
16
+ id?: string;
17
+ selector?: string;
18
+ selectorIndex?: number;
19
+ sourceFile: string;
20
+ };
21
+
22
+ afterEach(() => {
23
+ document.body.innerHTML = "";
24
+ usePlayerStore.getState().reset();
25
+ vi.restoreAllMocks();
26
+ vi.unstubAllGlobals();
27
+ });
28
+
29
+ function createPreviewIframe(
30
+ clips: Array<{
31
+ id: string;
32
+ track: number;
33
+ style?: string;
34
+ }> = [
35
+ { id: "front", track: 0 },
36
+ { id: "back", track: 1 },
37
+ ],
38
+ ): HTMLIFrameElement {
39
+ const iframe = document.createElement("iframe");
40
+ document.body.append(iframe);
41
+ const doc = iframe.contentDocument;
42
+ if (!doc) throw new Error("Expected iframe document");
43
+ doc.body.innerHTML = clips
44
+ .map(
45
+ (clip) =>
46
+ `<div id="${clip.id}" data-start="0" data-duration="2" data-track-index="${clip.track}"${
47
+ clip.style ? ` style="${clip.style}"` : ""
48
+ }></div>`,
49
+ )
50
+ .join("\n");
51
+ return iframe;
52
+ }
53
+
54
+ function timelineElement(input: {
55
+ id: string;
56
+ track: number;
57
+ zIndex: number;
58
+ tag?: string;
59
+ start?: number;
60
+ duration?: number;
61
+ sourceFile?: string;
62
+ }): TimelineElement {
63
+ return {
64
+ id: input.id,
65
+ domId: input.id,
66
+ hfId: `hf-${input.id}`,
67
+ tag: input.tag ?? "div",
68
+ start: input.start ?? 0,
69
+ duration: input.duration ?? 2,
70
+ track: input.track,
71
+ zIndex: input.zIndex,
72
+ stackingContextId: "root",
73
+ parentCompositionId: null,
74
+ compositionAncestors: ["root"],
75
+ sourceFile: input.sourceFile ?? "index.html",
76
+ timingSource: "authored",
77
+ };
78
+ }
79
+
80
+ function renderTimelineEditingHook(input: {
81
+ timelineElements: TimelineElement[];
82
+ iframe: HTMLIFrameElement;
83
+ onZIndexCommit: (entries: ZIndexEntry[]) => Promise<void>;
84
+ projectId?: string | null;
85
+ writeProjectFile?: (path: string, content: string) => Promise<void>;
86
+ recordEdit?: (input: {
87
+ label: string;
88
+ kind: string;
89
+ coalesceKey?: string;
90
+ files: Record<string, { before: string; after: string }>;
91
+ }) => Promise<void>;
92
+ reloadPreview?: () => void;
93
+ sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
94
+ forceReloadSdkSession?: () => void;
95
+ }): {
96
+ move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
97
+ resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
98
+ groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
99
+ groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
100
+ unmount: () => void;
101
+ } {
102
+ let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
103
+ let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
104
+ let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
105
+ let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
106
+
107
+ function Harness() {
108
+ const commitRef = useRef(input.onZIndexCommit);
109
+ commitRef.current = input.onZIndexCommit;
110
+ const hook = useTimelineEditing({
111
+ projectId: input.projectId ?? null,
112
+ activeCompPath: "index.html",
113
+ timelineElements: input.timelineElements,
114
+ showToast: vi.fn(),
115
+ writeProjectFile: input.writeProjectFile ?? vi.fn(),
116
+ recordEdit: input.recordEdit ?? vi.fn(),
117
+ domEditSaveTimestampRef: { current: 0 },
118
+ reloadPreview: input.reloadPreview ?? vi.fn(),
119
+ previewIframeRef: { current: input.iframe },
120
+ pendingTimelineEditPathRef: { current: new Set<string>() },
121
+ uploadProjectFiles: vi.fn(),
122
+ sdkSession: input.sdkSession,
123
+ forceReloadSdkSession: input.forceReloadSdkSession,
124
+ handleDomZIndexReorderCommitRef: commitRef,
125
+ });
126
+ move = hook.handleTimelineElementMove;
127
+ resize = hook.handleTimelineElementResize;
128
+ groupMove = hook.handleTimelineGroupMove;
129
+ groupResize = hook.handleTimelineGroupResize;
130
+ return null;
131
+ }
132
+
133
+ const host = document.createElement("div");
134
+ document.body.append(host);
135
+ const root = createRoot(host);
136
+ act(() => {
137
+ root.render(<Harness />);
138
+ });
139
+
140
+ if (!move) throw new Error("Expected hook to expose move handler");
141
+ if (!resize) throw new Error("Expected hook to expose resize handler");
142
+ if (!groupMove) throw new Error("Expected hook to expose group move handler");
143
+ if (!groupResize) throw new Error("Expected hook to expose group resize handler");
144
+ return {
145
+ move,
146
+ resize,
147
+ groupMove,
148
+ groupResize,
149
+ unmount: () => {
150
+ act(() => root.unmount());
151
+ },
152
+ };
153
+ }
154
+
155
+ type TimelineRecordEdit = NonNullable<
156
+ Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
157
+ >;
158
+
159
+ function renderTimelineEditingHookWithLifecycle(input: {
160
+ timelineElements: TimelineElement[];
161
+ iframe: HTMLIFrameElement;
162
+ commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
163
+ }): {
164
+ move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
165
+ unmount: () => void;
166
+ } {
167
+ let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
168
+
169
+ function Harness() {
170
+ const lifecycle = useElementLifecycleOps({
171
+ activeCompPath: "index.html",
172
+ showToast: vi.fn(),
173
+ writeProjectFile: vi.fn(),
174
+ domEditSaveTimestampRef: { current: 0 },
175
+ editHistory: { recordEdit: vi.fn() },
176
+ projectIdRef: { current: "p1" },
177
+ reloadPreview: vi.fn(),
178
+ clearDomSelection: vi.fn(),
179
+ commitPositionPatchToHtml: input.commitPositionPatchToHtml,
180
+ });
181
+ const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
182
+ commitRef.current = lifecycle.handleDomZIndexReorderCommit;
183
+ const hook = useTimelineEditing({
184
+ projectId: null,
185
+ activeCompPath: "index.html",
186
+ timelineElements: input.timelineElements,
187
+ showToast: vi.fn(),
188
+ writeProjectFile: vi.fn(),
189
+ recordEdit: vi.fn(),
190
+ domEditSaveTimestampRef: { current: 0 },
191
+ reloadPreview: vi.fn(),
192
+ previewIframeRef: { current: input.iframe },
193
+ pendingTimelineEditPathRef: { current: new Set<string>() },
194
+ uploadProjectFiles: vi.fn(),
195
+ handleDomZIndexReorderCommitRef: commitRef,
196
+ });
197
+ move = hook.handleTimelineElementMove;
198
+ return null;
199
+ }
200
+
201
+ const host = document.createElement("div");
202
+ document.body.append(host);
203
+ const root = createRoot(host);
204
+ act(() => {
205
+ root.render(<Harness />);
206
+ });
207
+
208
+ if (!move) throw new Error("Expected hook to expose move handler");
209
+ return {
210
+ move,
211
+ unmount: () => {
212
+ act(() => root.unmount());
213
+ },
214
+ };
215
+ }
216
+
217
+ function jsonResponse(body: unknown): Response {
218
+ return new Response(JSON.stringify(body), {
219
+ status: 200,
220
+ headers: { "content-type": "application/json" },
221
+ });
222
+ }
223
+
224
+ function requestUrl(input: Parameters<typeof fetch>[0]): string {
225
+ if (typeof input === "string") return input;
226
+ if (input instanceof URL) return input.toString();
227
+ return input.url;
228
+ }
229
+
230
+ async function flushAsyncWork(): Promise<void> {
231
+ for (let i = 0; i < 8; i += 1) {
232
+ await Promise.resolve();
233
+ }
234
+ }
235
+
236
+ describe("useTimelineEditing timeline z-index reorder", () => {
237
+ it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
238
+ const source = [
239
+ `<div data-composition-id="main" data-duration="4">`,
240
+ ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
241
+ `</div>`,
242
+ ].join("\n");
243
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
244
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
245
+ const sdkSession = await openComposition(source);
246
+ const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
247
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
248
+ const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
249
+ const forceReloadSdkSession = vi.fn();
250
+ const reloadPreview = vi.fn();
251
+ const iframeWindow = iframe.contentWindow;
252
+ if (!iframeWindow) throw new Error("Expected iframe window");
253
+ const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
254
+ vi.stubGlobal(
255
+ "fetch",
256
+ vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
257
+ const url = requestUrl(input);
258
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
259
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
260
+ return jsonResponse({ ok: true, mutated: false });
261
+ }
262
+ throw new Error(`Unexpected fetch: ${url}`);
263
+ }),
264
+ );
265
+ usePlayerStore.getState().setDuration(4);
266
+ const { move, unmount } = renderTimelineEditingHook({
267
+ timelineElements: [clip],
268
+ iframe,
269
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
270
+ projectId: "p1",
271
+ writeProjectFile,
272
+ recordEdit,
273
+ sdkSession,
274
+ forceReloadSdkSession,
275
+ reloadPreview,
276
+ });
277
+
278
+ await act(async () => {
279
+ await move(clip, { start: 3, track: clip.track });
280
+ });
281
+
282
+ expect(setTimingSpy).not.toHaveBeenCalled();
283
+ expect(writeProjectFile.mock.calls[0]![1]).toContain(
284
+ 'data-composition-id="main" data-duration="5"',
285
+ );
286
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
287
+ expect(usePlayerStore.getState().duration).toBe(5);
288
+ expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
289
+ expect(reloadPreview).not.toHaveBeenCalled();
290
+ expect(postMessageSpy).toHaveBeenCalledWith(
291
+ {
292
+ source: "hf-parent",
293
+ type: "control",
294
+ action: "set-root-duration",
295
+ durationSeconds: 5,
296
+ },
297
+ "*",
298
+ );
299
+
300
+ unmount();
301
+ });
302
+
303
+ it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
304
+ const source = [
305
+ `<div data-composition-id="main" data-duration="4">`,
306
+ ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
307
+ `</div>`,
308
+ ].join("\n");
309
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
310
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
311
+ const sdkSession = await openComposition(source);
312
+ const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
313
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
314
+ const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
315
+ const forceReloadSdkSession = vi.fn();
316
+ const reloadPreview = vi.fn();
317
+ const iframeWindow = iframe.contentWindow;
318
+ if (!iframeWindow) throw new Error("Expected iframe window");
319
+ const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
320
+ vi.stubGlobal(
321
+ "fetch",
322
+ vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
323
+ const url = requestUrl(input);
324
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
325
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
326
+ return jsonResponse({ ok: true, mutated: false });
327
+ }
328
+ throw new Error(`Unexpected fetch: ${url}`);
329
+ }),
330
+ );
331
+ usePlayerStore.getState().setDuration(4);
332
+ const { resize, unmount } = renderTimelineEditingHook({
333
+ timelineElements: [clip],
334
+ iframe,
335
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
336
+ projectId: "p1",
337
+ writeProjectFile,
338
+ recordEdit,
339
+ sdkSession,
340
+ forceReloadSdkSession,
341
+ reloadPreview,
342
+ });
343
+
344
+ await act(async () => {
345
+ await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
346
+ });
347
+
348
+ expect(setTimingSpy).not.toHaveBeenCalled();
349
+ expect(writeProjectFile.mock.calls[0]![1]).toContain(
350
+ 'data-composition-id="main" data-duration="5"',
351
+ );
352
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
353
+ expect(usePlayerStore.getState().duration).toBe(5);
354
+ expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
355
+ expect(reloadPreview).not.toHaveBeenCalled();
356
+ expect(postMessageSpy).toHaveBeenCalledWith(
357
+ {
358
+ source: "hf-parent",
359
+ type: "control",
360
+ action: "set-root-duration",
361
+ durationSeconds: 5,
362
+ },
363
+ "*",
364
+ );
365
+
366
+ unmount();
367
+ });
368
+
369
+ it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
370
+ const iframe = createPreviewIframe([
371
+ { id: "front", track: 0, style: "position: relative; z-index: 10" },
372
+ { id: "back", track: 2, style: "position: relative; z-index: 1" },
373
+ ]);
374
+ const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
375
+ const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
376
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
377
+ const { move, unmount } = renderTimelineEditingHook({
378
+ timelineElements: [front, back],
379
+ iframe,
380
+ onZIndexCommit: commit,
381
+ });
382
+
383
+ await act(async () => {
384
+ await move(back, {
385
+ start: back.start,
386
+ track: back.track,
387
+ stackingReorder: {
388
+ contextKey: "root",
389
+ placement: { type: "onto", layerId: "layer-front" },
390
+ zIndexChanges: [{ key: "back", zIndex: 10 }],
391
+ },
392
+ });
393
+ });
394
+
395
+ const doc = iframe.contentDocument;
396
+ if (!doc) throw new Error("Expected iframe document");
397
+
398
+ expect(commit).toHaveBeenCalledTimes(1);
399
+ expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
400
+ ["back", 10],
401
+ ]);
402
+ expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2");
403
+
404
+ unmount();
405
+ });
406
+
407
+ it("never writes z-index when the dragged clip is audio (no visual layer)", async () => {
408
+ const iframe = createPreviewIframe([
409
+ { id: "front", track: 0 },
410
+ { id: "music", track: 1 },
411
+ ]);
412
+ const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
413
+ const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" });
414
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
415
+ const { move, unmount } = renderTimelineEditingHook({
416
+ timelineElements: [front, music],
417
+ iframe,
418
+ onZIndexCommit: commit,
419
+ });
420
+
421
+ await act(async () => {
422
+ await move(music, {
423
+ start: music.start,
424
+ track: music.track,
425
+ stackingReorder: {
426
+ contextKey: "root",
427
+ placement: { type: "onto", layerId: "layer-front" },
428
+ zIndexChanges: [{ key: "music", zIndex: 2 }],
429
+ },
430
+ });
431
+ });
432
+
433
+ expect(commit).not.toHaveBeenCalled();
434
+
435
+ unmount();
436
+ });
437
+
438
+ it("commits only the minimum z-index changes resolved by the timeline drag", async () => {
439
+ const iframe = createPreviewIframe([
440
+ { id: "front", track: 0, style: "position: relative; z-index: 2" },
441
+ { id: "back", track: 1, style: "position: relative; z-index: 1" },
442
+ { id: "dragged", track: 2, style: "position: relative; z-index: 0" },
443
+ ]);
444
+ const front = timelineElement({ id: "front", track: 0, zIndex: 2 });
445
+ const back = timelineElement({ id: "back", track: 1, zIndex: 1 });
446
+ const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 });
447
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
448
+ const { move, unmount } = renderTimelineEditingHook({
449
+ timelineElements: [front, back, dragged],
450
+ iframe,
451
+ onZIndexCommit: commit,
452
+ });
453
+
454
+ await act(async () => {
455
+ await move(dragged, {
456
+ start: dragged.start,
457
+ track: dragged.track,
458
+ stackingReorder: {
459
+ contextKey: "root",
460
+ placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" },
461
+ zIndexChanges: [
462
+ { key: "dragged", zIndex: 2 },
463
+ { key: "front", zIndex: 3 },
464
+ ],
465
+ },
466
+ });
467
+ });
468
+
469
+ expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
470
+ ["dragged", 2],
471
+ ["front", 3],
472
+ ]);
473
+
474
+ unmount();
475
+ });
476
+
477
+ it("uses the shared lifecycle commit so static clips receive position relative", async () => {
478
+ const iframe = createPreviewIframe([
479
+ { id: "front", track: 0, style: "position: static" },
480
+ { id: "back", track: 1, style: "position: static" },
481
+ ]);
482
+ const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
483
+ const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
484
+ const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
485
+ const { move, unmount } = renderTimelineEditingHookWithLifecycle({
486
+ timelineElements: [front, back],
487
+ iframe,
488
+ commitPositionPatchToHtml,
489
+ });
490
+
491
+ await act(async () => {
492
+ await move(back, {
493
+ start: back.start,
494
+ track: back.track,
495
+ stackingReorder: {
496
+ contextKey: "root",
497
+ placement: { type: "above", layerId: "front" },
498
+ zIndexChanges: [{ key: "back", zIndex: 2 }],
499
+ },
500
+ });
501
+ await flushAsyncWork();
502
+ });
503
+
504
+ expect(commitPositionPatchToHtml).toHaveBeenCalled();
505
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
506
+ { type: "inline-style", property: "z-index", value: "2" },
507
+ { type: "inline-style", property: "position", value: "relative" },
508
+ ]);
509
+
510
+ unmount();
511
+ });
512
+
513
+ it("rejects and rolls back DOM and store z-index changes when a reorder save fails", async () => {
514
+ const iframe = createPreviewIframe([
515
+ { id: "front", track: 0, style: "position: relative; z-index: 7" },
516
+ { id: "back", track: 1, style: "position: static" },
517
+ ]);
518
+ const front = timelineElement({ id: "front", track: 0, zIndex: 7 });
519
+ const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
520
+ usePlayerStore.getState().setElements([
521
+ { ...front, hasExplicitZIndex: true },
522
+ { ...back, hasExplicitZIndex: false },
523
+ ]);
524
+ const saveError = new Error("save failed");
525
+ const commitPositionPatchToHtml = vi
526
+ .fn<(...args: unknown[]) => Promise<void>>()
527
+ .mockResolvedValueOnce(undefined)
528
+ .mockRejectedValueOnce(saveError);
529
+ const { move, unmount } = renderTimelineEditingHookWithLifecycle({
530
+ timelineElements: [front, back],
531
+ iframe,
532
+ commitPositionPatchToHtml,
533
+ });
534
+ const doc = iframe.contentDocument;
535
+ if (!doc) throw new Error("Expected iframe document");
536
+ const frontElement = doc.getElementById("front") as HTMLElement | null;
537
+ const backElement = doc.getElementById("back") as HTMLElement | null;
538
+ if (!frontElement || !backElement) throw new Error("Expected reordered elements");
539
+
540
+ let rejection: unknown;
541
+ await act(async () => {
542
+ try {
543
+ await move(back, {
544
+ start: back.start,
545
+ track: back.track,
546
+ stackingReorder: {
547
+ contextKey: "root",
548
+ placement: { type: "above", layerId: "front" },
549
+ zIndexChanges: [
550
+ { key: "front", zIndex: 2 },
551
+ { key: "back", zIndex: 5 },
552
+ ],
553
+ },
554
+ });
555
+ } catch (error) {
556
+ rejection = error;
557
+ }
558
+ await flushAsyncWork();
559
+ });
560
+
561
+ expect(rejection).toBe(saveError);
562
+ expect(frontElement.style.zIndex).toBe("7");
563
+ expect(frontElement.style.position).toBe("relative");
564
+ expect(backElement.style.zIndex).toBe("");
565
+ expect(backElement.style.position).toBe("static");
566
+ const storeEntries = usePlayerStore.getState().elements;
567
+ expect(storeEntries.find((entry) => entry.id === "front")).toMatchObject({
568
+ zIndex: 7,
569
+ hasExplicitZIndex: true,
570
+ });
571
+ expect(storeEntries.find((entry) => entry.id === "back")).toMatchObject({
572
+ zIndex: 0,
573
+ hasExplicitZIndex: false,
574
+ });
575
+
576
+ unmount();
577
+ });
578
+
579
+ it("waits for every lifecycle z-index save before resolving a reorder", async () => {
580
+ const iframe = createPreviewIframe([
581
+ { id: "front", track: 0, style: "position: relative; z-index: 1" },
582
+ { id: "back", track: 1, style: "position: relative; z-index: 0" },
583
+ ]);
584
+ const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
585
+ const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
586
+ let releaseFirst!: () => void;
587
+ let releaseSecond!: () => void;
588
+ const firstSave = new Promise<void>((resolve) => {
589
+ releaseFirst = resolve;
590
+ });
591
+ const secondSave = new Promise<void>((resolve) => {
592
+ releaseSecond = resolve;
593
+ });
594
+ const commitPositionPatchToHtml = vi
595
+ .fn<(...args: unknown[]) => Promise<void>>()
596
+ .mockReturnValueOnce(firstSave)
597
+ .mockReturnValueOnce(secondSave);
598
+ const { move, unmount } = renderTimelineEditingHookWithLifecycle({
599
+ timelineElements: [front, back],
600
+ iframe,
601
+ commitPositionPatchToHtml,
602
+ });
603
+ let settled = false;
604
+
605
+ let movePromise!: Promise<void>;
606
+ await act(async () => {
607
+ movePromise = move(back, {
608
+ start: back.start,
609
+ track: back.track,
610
+ stackingReorder: {
611
+ contextKey: "root",
612
+ placement: { type: "above", layerId: "front" },
613
+ zIndexChanges: [
614
+ { key: "front", zIndex: 2 },
615
+ { key: "back", zIndex: 3 },
616
+ ],
617
+ },
618
+ }).then(() => {
619
+ settled = true;
620
+ });
621
+ await flushAsyncWork();
622
+ });
623
+
624
+ expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
625
+ expect(settled).toBe(false);
626
+
627
+ await act(async () => {
628
+ releaseFirst();
629
+ await flushAsyncWork();
630
+ });
631
+ expect(settled).toBe(false);
632
+
633
+ await act(async () => {
634
+ releaseSecond();
635
+ await movePromise;
636
+ await flushAsyncWork();
637
+ });
638
+ expect(settled).toBe(true);
639
+
640
+ unmount();
641
+ });
642
+
643
+ it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
644
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
645
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
646
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
647
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
648
+ const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
649
+ const reloadPreview = vi.fn();
650
+ const fetchMock = vi.fn(
651
+ async (
652
+ input: Parameters<typeof fetch>[0],
653
+ _init?: Parameters<typeof fetch>[1],
654
+ ): Promise<Response> => {
655
+ const url = requestUrl(input);
656
+ if (url.includes("/api/projects/p1/files/")) {
657
+ return jsonResponse({
658
+ content: '<div id="clip" data-start="0" data-track-index="0"></div>',
659
+ });
660
+ }
661
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
662
+ return jsonResponse({ ok: true });
663
+ }
664
+ throw new Error(`Unexpected fetch: ${url}`);
665
+ },
666
+ );
667
+ vi.stubGlobal("fetch", fetchMock);
668
+ const { move, unmount } = renderTimelineEditingHook({
669
+ timelineElements: [clip],
670
+ iframe,
671
+ onZIndexCommit: commit,
672
+ projectId: "p1",
673
+ writeProjectFile,
674
+ recordEdit,
675
+ reloadPreview,
676
+ });
677
+
678
+ await act(async () => {
679
+ await move(clip, { start: 1.25, track: clip.track });
680
+ });
681
+
682
+ const doc = iframe.contentDocument;
683
+ if (!doc) throw new Error("Expected iframe document");
684
+ expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
685
+ expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
686
+ expect(commit).not.toHaveBeenCalled();
687
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
688
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
689
+ expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
690
+ expect(
691
+ fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
692
+ ).toBe(true);
693
+
694
+ unmount();
695
+ });
696
+
697
+ it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
698
+ const iframe = createPreviewIframe([
699
+ { id: "clip", track: 0, style: "position: relative; z-index: 0" },
700
+ ]);
701
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
702
+ // Gate the z-index commit so we can observe whether the timing write waits.
703
+ let releaseCommit!: () => void;
704
+ const commitGate = new Promise<void>((resolve) => {
705
+ releaseCommit = resolve;
706
+ });
707
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
708
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
709
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
710
+ const url = requestUrl(input);
711
+ if (url.includes("/api/projects/p1/files/")) {
712
+ return jsonResponse({
713
+ content: '<div id="clip" data-start="0" data-track-index="0"></div>',
714
+ });
715
+ }
716
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
717
+ throw new Error(`Unexpected fetch: ${url}`);
718
+ });
719
+ vi.stubGlobal("fetch", fetchMock);
720
+ const { move, unmount } = renderTimelineEditingHook({
721
+ timelineElements: [clip],
722
+ iframe,
723
+ onZIndexCommit: commit,
724
+ projectId: "p1",
725
+ writeProjectFile,
726
+ recordEdit: vi.fn(async () => {}),
727
+ });
728
+
729
+ // Diagonal drag: both a time move (start change) and a restack (z-index change).
730
+ let movePromise!: Promise<unknown>;
731
+ await act(async () => {
732
+ movePromise = move(clip, {
733
+ start: 1.25,
734
+ track: clip.track,
735
+ stackingReorder: {
736
+ contextKey: "root",
737
+ placement: { type: "onto", layerId: "layer-clip" },
738
+ zIndexChanges: [{ key: "clip", zIndex: 5 }],
739
+ },
740
+ });
741
+ await flushAsyncWork();
742
+ });
743
+
744
+ // The z-index commit is in flight but gated; the full-file timing write must
745
+ // not have run yet, or it would overwrite the file without the z-index change.
746
+ expect(commit).toHaveBeenCalledTimes(1);
747
+ expect(writeProjectFile).not.toHaveBeenCalled();
748
+
749
+ // Release the z-index commit → the timing write now proceeds, on top of it.
750
+ await act(async () => {
751
+ releaseCommit();
752
+ await movePromise;
753
+ await flushAsyncWork();
754
+ });
755
+ expect(writeProjectFile).toHaveBeenCalled();
756
+
757
+ unmount();
758
+ });
759
+
760
+ it("persists a same-file group move with one write containing every clip timing", async () => {
761
+ const source = [
762
+ '<div id="a" data-start="0" data-duration="1"></div>',
763
+ '<div id="b" data-start="1" data-duration="1"></div>',
764
+ '<div id="c" data-start="2" data-duration="1"></div>',
765
+ ].join("\n");
766
+ const iframe = createPreviewIframe([
767
+ { id: "a", track: 0 },
768
+ { id: "b", track: 1 },
769
+ { id: "c", track: 2 },
770
+ ]);
771
+ const clips = [
772
+ timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }),
773
+ timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }),
774
+ timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }),
775
+ ];
776
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
777
+ const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
778
+ vi.stubGlobal(
779
+ "fetch",
780
+ vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
781
+ const url = requestUrl(input);
782
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
783
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
784
+ throw new Error(`Unexpected fetch: ${url}`);
785
+ }),
786
+ );
787
+ const { groupMove, unmount } = renderTimelineEditingHook({
788
+ timelineElements: clips,
789
+ iframe,
790
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
791
+ projectId: "p1",
792
+ writeProjectFile,
793
+ recordEdit,
794
+ });
795
+
796
+ await act(async () => {
797
+ await groupMove([
798
+ { element: clips[0], start: 0.5 },
799
+ { element: clips[1], start: 1.5 },
800
+ { element: clips[2], start: 2.5 },
801
+ ]);
802
+ });
803
+
804
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
805
+ const written = writeProjectFile.mock.calls[0]![1] as string;
806
+ expect(written).toContain('id="a" data-start="0.5"');
807
+ expect(written).toContain('id="b" data-start="1.5"');
808
+ expect(written).toContain('id="c" data-start="2.5"');
809
+ expect(recordEdit).toHaveBeenCalledTimes(1);
810
+ expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]);
811
+
812
+ unmount();
813
+ });
814
+
815
+ it("partitions a group move by source file while keeping one undo entry", async () => {
816
+ const files: Record<string, string> = {
817
+ "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
818
+ "scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
819
+ };
820
+ const iframe = createPreviewIframe([
821
+ { id: "a", track: 0 },
822
+ { id: "b", track: 1 },
823
+ ]);
824
+ const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
825
+ const b = timelineElement({
826
+ id: "b",
827
+ track: 1,
828
+ zIndex: 0,
829
+ start: 1,
830
+ duration: 1,
831
+ sourceFile: "scene.html",
832
+ });
833
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
834
+ const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
835
+ vi.stubGlobal(
836
+ "fetch",
837
+ vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
838
+ const url = requestUrl(input);
839
+ if (url.includes("/api/projects/p1/files/")) {
840
+ const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
841
+ return jsonResponse({ content: files[path] });
842
+ }
843
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
844
+ throw new Error(`Unexpected fetch: ${url}`);
845
+ }),
846
+ );
847
+ const { groupMove, unmount } = renderTimelineEditingHook({
848
+ timelineElements: [a, b],
849
+ iframe,
850
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
851
+ projectId: "p1",
852
+ writeProjectFile,
853
+ recordEdit,
854
+ });
855
+
856
+ await act(async () => {
857
+ await groupMove([
858
+ { element: a, start: 0.25 },
859
+ { element: b, start: 1.25 },
860
+ ]);
861
+ });
862
+
863
+ expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([
864
+ "index.html",
865
+ "scene.html",
866
+ ]);
867
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"');
868
+ expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"');
869
+ expect(recordEdit).toHaveBeenCalledTimes(1);
870
+ expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([
871
+ "index.html",
872
+ "scene.html",
873
+ ]);
874
+
875
+ unmount();
876
+ });
877
+
878
+ it("waits for a z-index commit before the group timing write", async () => {
879
+ const source = '<div id="clip" data-start="0" data-duration="1"></div>';
880
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
881
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
882
+ let releaseCommit!: () => void;
883
+ const zIndexCommit = new Promise<void>((resolve) => {
884
+ releaseCommit = resolve;
885
+ });
886
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
887
+ vi.stubGlobal(
888
+ "fetch",
889
+ vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
890
+ const url = requestUrl(input);
891
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
892
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
893
+ throw new Error(`Unexpected fetch: ${url}`);
894
+ }),
895
+ );
896
+ const { groupMove, unmount } = renderTimelineEditingHook({
897
+ timelineElements: [clip],
898
+ iframe,
899
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
900
+ projectId: "p1",
901
+ writeProjectFile,
902
+ recordEdit: vi.fn(async () => {}),
903
+ });
904
+
905
+ let movePromise!: Promise<unknown>;
906
+ await act(async () => {
907
+ movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit });
908
+ await flushAsyncWork();
909
+ });
910
+ expect(writeProjectFile).not.toHaveBeenCalled();
911
+
912
+ await act(async () => {
913
+ releaseCommit();
914
+ await movePromise;
915
+ await flushAsyncWork();
916
+ });
917
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
918
+
919
+ unmount();
920
+ });
921
+
922
+ it("matches the single-clip move output when a group move contains one clip", async () => {
923
+ const source = '<div id="clip" data-start="0" data-duration="1"></div>';
924
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
925
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
926
+ const url = requestUrl(input);
927
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
928
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
929
+ throw new Error(`Unexpected fetch: ${url}`);
930
+ });
931
+ vi.stubGlobal("fetch", fetchMock);
932
+
933
+ const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
934
+ const single = renderTimelineEditingHook({
935
+ timelineElements: [clip],
936
+ iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
937
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
938
+ projectId: "p1",
939
+ writeProjectFile: singleWrite,
940
+ recordEdit: vi.fn(async () => {}),
941
+ });
942
+ await act(async () => {
943
+ await single.move(clip, { start: 0.5, track: clip.track });
944
+ });
945
+ single.unmount();
946
+
947
+ const groupWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
948
+ const group = renderTimelineEditingHook({
949
+ timelineElements: [clip],
950
+ iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
951
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
952
+ projectId: "p1",
953
+ writeProjectFile: groupWrite,
954
+ recordEdit: vi.fn(async () => {}),
955
+ });
956
+ await act(async () => {
957
+ await group.groupMove([{ element: clip, start: 0.5 }]);
958
+ });
959
+
960
+ expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
961
+ group.unmount();
962
+ });
963
+ });