@hyperframes/studio 0.7.57 → 0.7.58

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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { openComposition } from "@hyperframes/sdk";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { usePlayerStore, type TimelineElement } from "../player";
8
+ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
8
9
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
9
10
  import { useTimelineEditing } from "./useTimelineEditing";
10
11
 
@@ -165,7 +166,11 @@ type TimelineRecordEdit = NonNullable<
165
166
  function renderTimelineEditingHookWithLifecycle(input: {
166
167
  timelineElements: TimelineElement[];
167
168
  iframe: HTMLIFrameElement;
168
- commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
169
+ commitDomEditPatchBatches: ReturnType<
170
+ typeof vi.fn<
171
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
172
+ >
173
+ >;
169
174
  }): {
170
175
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
171
176
  unmount: () => void;
@@ -209,19 +214,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
209
214
  return { move, unmount };
210
215
  }
211
216
 
212
- function jsonResponse(body: unknown): Response {
213
- return new Response(JSON.stringify(body), {
214
- status: 200,
215
- headers: { "content-type": "application/json" },
216
- });
217
- }
218
-
219
- function requestUrl(input: Parameters<typeof fetch>[0]): string {
220
- if (typeof input === "string") return input;
221
- if (input instanceof URL) return input.toString();
222
- return input.url;
223
- }
224
-
225
217
  async function flushAsyncWork(): Promise<void> {
226
218
  for (let i = 0; i < 8; i += 1) {
227
219
  await Promise.resolve();
@@ -233,24 +225,99 @@ async function flushAsyncWork(): Promise<void> {
233
225
  * string, or a path → content map) and answers the GSAP-mutation endpoint
234
226
  * with `gsapBody`. Returns the mock for call inspection.
235
227
  */
236
- function stubProjectFetch(
237
- files: string | Record<string, string>,
238
- gsapBody: unknown = { ok: true },
239
- ) {
228
+ function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
229
+ // Keep this test server's capability, file-read, and mutation routes together;
230
+ // splitting the fixture would obscure the request sequence asserted by callers.
231
+ // fallow-ignore-next-line complexity
240
232
  const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
241
233
  const url = requestUrl(input);
234
+ if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
235
+ return jsonResponse({ atomicOwnershipPairs: true });
236
+ }
242
237
  if (url.includes("/api/projects/p1/files/")) {
243
238
  if (typeof files === "string") return jsonResponse({ content: files });
244
239
  const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
245
240
  return jsonResponse({ content: files[path] });
246
241
  }
247
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
242
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
243
+ const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
244
+ const content = typeof files === "string" ? files : (files[path] ?? "");
245
+ return jsonResponse(
246
+ gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
247
+ );
248
+ }
248
249
  throw new Error(`Unexpected fetch: ${url}`);
249
250
  });
250
251
  vi.stubGlobal("fetch", fetchMock);
251
252
  return fetchMock;
252
253
  }
253
254
 
255
+ /**
256
+ * One clip on track 0 + the editing hook wired to project "p1" — shared setup
257
+ * for the single-clip move-path tests (horizontal, vertical-only, track-only,
258
+ * diagonal) so their arrange blocks aren't clones of each other.
259
+ */
260
+ function setupSingleClipHarness(options?: {
261
+ source?: string;
262
+ clipStyle?: string;
263
+ onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
264
+ }) {
265
+ const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
266
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
267
+ const commit =
268
+ options?.onZIndexCommit ??
269
+ vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
270
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
271
+ const reloadPreview = vi.fn();
272
+ const fetchMock = stubProjectFetch(
273
+ options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
274
+ );
275
+ const hook = renderTimelineEditingHook({
276
+ timelineElements: [clip],
277
+ iframe,
278
+ onZIndexCommit: commit,
279
+ projectId: "p1",
280
+ writeProjectFile,
281
+ recordEdit: vi.fn(async () => {}),
282
+ reloadPreview,
283
+ });
284
+ return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
285
+ }
286
+
287
+ /** Assert a lane write landed in both the live iframe DOM and the persisted file. */
288
+ function expectLanePersisted(
289
+ iframe: HTMLIFrameElement,
290
+ writeProjectFile: { mock: { calls: unknown[][] } },
291
+ track: string,
292
+ ): void {
293
+ const doc = iframe.contentDocument;
294
+ if (!doc) throw new Error("Expected iframe document");
295
+ expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track);
296
+ expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`);
297
+ }
298
+
299
+ /**
300
+ * Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe.
301
+ * Shared by the group-move tests; `bSourceFile` puts b in its own file for the
302
+ * cross-file partition test.
303
+ */
304
+ function makeTwoClipPair(bSourceFile?: string) {
305
+ const iframe = createPreviewIframe([
306
+ { id: "a", track: 0 },
307
+ { id: "b", track: 1 },
308
+ ]);
309
+ const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
310
+ const b = timelineElement({
311
+ id: "b",
312
+ track: 1,
313
+ zIndex: 0,
314
+ start: 1,
315
+ duration: 1,
316
+ sourceFile: bSourceFile,
317
+ });
318
+ return { iframe, a, b };
319
+ }
320
+
254
321
  const ROOT_DURATION_FALLBACK_SOURCE = [
255
322
  `<div data-composition-id="main" data-duration="4">`,
256
323
  ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
@@ -270,7 +337,12 @@ async function setupRootDurationFallback() {
270
337
  const iframeWindow = iframe.contentWindow;
271
338
  if (!iframeWindow) throw new Error("Expected iframe window");
272
339
  const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
273
- stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
340
+ stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, {
341
+ mutated: false,
342
+ scriptText: null,
343
+ before: ROOT_DURATION_FALLBACK_SOURCE,
344
+ after: ROOT_DURATION_FALLBACK_SOURCE,
345
+ });
274
346
  usePlayerStore.getState().setDuration(4);
275
347
  const hook = renderTimelineEditingHook({
276
348
  timelineElements: [clip],
@@ -457,7 +529,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
457
529
  ]);
458
530
  const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
459
531
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
460
- const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
532
+ const commitDomEditPatchBatches = vi.fn<
533
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
534
+ >(async () => ({ durable: true, allMatched: true, changed: true }));
461
535
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
462
536
  timelineElements: [front, back],
463
537
  iframe,
@@ -502,7 +576,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
502
576
  ]);
503
577
  const saveError = new Error("save failed");
504
578
  const commitDomEditPatchBatches = vi
505
- .fn<(...args: unknown[]) => Promise<void>>()
579
+ .fn<
580
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
581
+ >()
506
582
  .mockRejectedValueOnce(saveError);
507
583
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
508
584
  timelineElements: [front, back],
@@ -566,8 +642,12 @@ describe("useTimelineEditing timeline z-index reorder", () => {
566
642
  releaseBatch = resolve;
567
643
  });
568
644
  const commitDomEditPatchBatches = vi
569
- .fn<(...args: unknown[]) => Promise<void>>()
570
- .mockReturnValueOnce(batchSave);
645
+ .fn<
646
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
647
+ >()
648
+ .mockReturnValueOnce(
649
+ batchSave.then(() => ({ durable: true, allMatched: true, changed: true })),
650
+ );
571
651
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
572
652
  timelineElements: [front, back],
573
653
  iframe,
@@ -608,98 +688,59 @@ describe("useTimelineEditing timeline z-index reorder", () => {
608
688
  });
609
689
 
610
690
  it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
611
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
612
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
613
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
614
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
615
- const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
616
- const reloadPreview = vi.fn();
617
- const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
618
- const { move, unmount } = renderTimelineEditingHook({
619
- timelineElements: [clip],
620
- iframe,
621
- onZIndexCommit: commit,
622
- projectId: "p1",
623
- writeProjectFile,
624
- recordEdit,
625
- reloadPreview,
626
- });
691
+ const h = setupSingleClipHarness();
627
692
 
628
693
  await act(async () => {
629
- await move(clip, { start: 1.25, track: clip.track });
694
+ await h.move(h.clip, { start: 1.25, track: h.clip.track });
630
695
  });
631
696
 
632
- const doc = iframe.contentDocument;
697
+ const doc = h.iframe.contentDocument;
633
698
  if (!doc) throw new Error("Expected iframe document");
634
699
  expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
635
700
  expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
636
- expect(commit).not.toHaveBeenCalled();
637
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
638
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
639
- expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
701
+ expect(h.commit).not.toHaveBeenCalled();
702
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
703
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
704
+ expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
640
705
  expect(
641
- fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
706
+ h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
642
707
  ).toBe(true);
643
708
 
644
- unmount();
709
+ h.unmount();
645
710
  });
646
711
 
647
712
  it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
648
713
  // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
649
714
  // pure lane change routed through onMoveElement (no onMoveElements wired)
650
715
  // wrote NOTHING — the lane snapped back on the next reload.
651
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
652
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
653
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
654
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
655
- stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
656
- const { move, unmount } = renderTimelineEditingHook({
657
- timelineElements: [clip],
658
- iframe,
659
- onZIndexCommit: commit,
660
- projectId: "p1",
661
- writeProjectFile,
662
- recordEdit: vi.fn(async () => {}),
663
- });
716
+ const h = setupSingleClipHarness();
664
717
 
665
718
  await act(async () => {
666
719
  // Vertical-only: same start, new track (already authored-space on this path).
667
- await move(clip, { start: clip.start, track: 2 });
720
+ await h.move(h.clip, { start: h.clip.start, track: 2 });
668
721
  });
669
722
 
670
- const doc = iframe.contentDocument;
671
- if (!doc) throw new Error("Expected iframe document");
672
- // Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
673
- expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
674
- // ...and the file write carries the new data-track-index with start intact.
675
- expect(writeProjectFile).toHaveBeenCalled();
676
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
677
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
723
+ // Live DOM patched (no pre-reload lane snap-back) and the file write
724
+ // carries the new data-track-index with start intact.
725
+ expectLanePersisted(h.iframe, h.writeProjectFile, "2");
726
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
678
727
 
679
- unmount();
728
+ h.unmount();
680
729
  });
681
730
 
682
731
  it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
683
- const iframe = createPreviewIframe([
684
- { id: "clip", track: 0, style: "position: relative; z-index: 0" },
685
- ]);
686
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
687
732
  // Gate the z-index commit so we can observe whether the timing write waits.
688
733
  let releaseCommit!: () => void;
689
734
  const commitGate = new Promise<void>((resolve) => {
690
735
  releaseCommit = resolve;
691
736
  });
692
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
693
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
694
- stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
695
- const { move, unmount } = renderTimelineEditingHook({
696
- timelineElements: [clip],
697
- iframe,
698
- onZIndexCommit: commit,
699
- projectId: "p1",
700
- writeProjectFile,
701
- recordEdit: vi.fn(async () => {}),
737
+ const h = setupSingleClipHarness({
738
+ clipStyle: "position: relative; z-index: 0",
739
+ onZIndexCommit: vi
740
+ .fn<(entries: ZIndexEntry[]) => Promise<void>>()
741
+ .mockReturnValue(commitGate),
702
742
  });
743
+ const { clip, commit, writeProjectFile, move, unmount } = h;
703
744
 
704
745
  // Diagonal drag: both a time move (start change) and a restack (z-index change).
705
746
  let movePromise!: Promise<unknown>;
@@ -784,19 +825,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
784
825
  "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
785
826
  "scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
786
827
  };
787
- const iframe = createPreviewIframe([
788
- { id: "a", track: 0 },
789
- { id: "b", track: 1 },
790
- ]);
791
- const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
792
- const b = timelineElement({
793
- id: "b",
794
- track: 1,
795
- zIndex: 0,
796
- start: 1,
797
- duration: 1,
798
- sourceFile: "scene.html",
799
- });
828
+ const { iframe, a, b } = makeTwoClipPair("scene.html");
800
829
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
801
830
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
802
831
  stubProjectFetch(files);
@@ -867,6 +896,148 @@ describe("useTimelineEditing timeline z-index reorder", () => {
867
896
  unmount();
868
897
  });
869
898
 
899
+ it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => {
900
+ // The mirrored z-order lane move persists {start: unchanged, track: new}.
901
+ // Nothing timing-related changed — data-track-index is never read by the
902
+ // renderer — so the persist must NOT be followed by the GSAP round-trip or
903
+ // the full preview reload (the reload is what blinked the canvas).
904
+ const h = setupSingleClipHarness({
905
+ source: '<div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>',
906
+ });
907
+
908
+ await act(async () => {
909
+ await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]);
910
+ await flushAsyncWork();
911
+ });
912
+
913
+ // The lane write still persisted (live DOM + file)...
914
+ expectLanePersisted(h.iframe, h.writeProjectFile, "2");
915
+ expect(h.writeProjectFile).toHaveBeenCalledTimes(1);
916
+ // ...but no GSAP mutation ran and the preview was NOT reloaded.
917
+ expect(
918
+ h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
919
+ ).toBe(false);
920
+ expect(h.reloadPreview).not.toHaveBeenCalled();
921
+
922
+ h.unmount();
923
+ });
924
+
925
+ it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => {
926
+ // The gap-close blink path: "Close gap" issues pure TIME moves through
927
+ // handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a
928
+ // .sub caption) has no id-addressed GSAP tweens, so the mutation step has
929
+ // nothing to rewrite and used to fall through to a full reloadPreview().
930
+ // The sync must instead rebind the runtime timing in place — seek + rebind
931
+ // re-derive the clip windows from the already-patched DOM — WITHOUT
932
+ // re-executing any script (a comp's init-style scripts, e.g. a three.js
933
+ // setup, must never run twice) and without the full-reload blink.
934
+ const iframe = document.createElement("iframe");
935
+ document.body.append(iframe);
936
+ const doc = iframe.contentDocument;
937
+ if (!doc) throw new Error("Expected iframe document");
938
+ doc.body.innerHTML =
939
+ '<div class="cap" data-start="2" data-duration="1" data-track-index="0"></div>';
940
+ const liveScript = doc.createElement("script");
941
+ liveScript.textContent =
942
+ 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };';
943
+ doc.body.appendChild(liveScript);
944
+ const win = iframe.contentWindow as unknown as Record<string, unknown>;
945
+ win.gsap = { timeline: vi.fn(), set: vi.fn() };
946
+ win.__timelines = { root: { kill: vi.fn() } };
947
+ win.__hfForceTimelineRebind = vi.fn();
948
+ win.__player = { getTime: () => 0, seek: vi.fn() };
949
+
950
+ const cap: TimelineElement = {
951
+ ...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }),
952
+ domId: undefined,
953
+ selector: ".cap",
954
+ selectorIndex: 0,
955
+ };
956
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
957
+ const reloadPreview = vi.fn();
958
+ const fetchMock = stubProjectFetch(
959
+ '<div class="cap" data-hf-id="hf-cap" data-start="2" data-duration="1" data-track-index="0"></div>',
960
+ );
961
+ const { groupMove, unmount } = renderTimelineEditingHook({
962
+ timelineElements: [cap],
963
+ iframe,
964
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
965
+ projectId: "p1",
966
+ writeProjectFile,
967
+ recordEdit: vi.fn(async () => {}),
968
+ reloadPreview,
969
+ });
970
+
971
+ await act(async () => {
972
+ // Mirror timelineGapCommit: a pure time move keeping the current lane.
973
+ await groupMove([{ element: cap, start: 1, track: cap.track }]);
974
+ await flushAsyncWork();
975
+ });
976
+
977
+ // The move persisted (live DOM + file)...
978
+ expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1");
979
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"');
980
+ // ...no GSAP mutation ran (nothing id-addressed to rewrite)...
981
+ expect(
982
+ fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
983
+ ).toBe(false);
984
+ // ...and the preview was rebound in place, NOT full-reloaded (blink),
985
+ // with the live script element left untouched (no re-execution).
986
+ expect(reloadPreview).not.toHaveBeenCalled();
987
+ expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
988
+ expect(doc.body.contains(liveScript)).toBe(true);
989
+ expect(doc.querySelectorAll("script")).toHaveLength(1);
990
+
991
+ unmount();
992
+ });
993
+
994
+ it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => {
995
+ // One clip changes lane only, the other shifts in time — the batch is not
996
+ // track-only, so the existing behavior (GSAP shift + full reload when no
997
+ // rewritten scriptText comes back — this stub iframe has no runtime rebind
998
+ // hook, so the in-place rebind can't apply) must be preserved.
999
+ const source = [
1000
+ '<div id="a" data-start="0" data-duration="1" data-track-index="0"></div>',
1001
+ '<div id="b" data-start="1" data-duration="1" data-track-index="1"></div>',
1002
+ ].join("\n");
1003
+ const { iframe, a, b } = makeTwoClipPair();
1004
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1005
+ const reloadPreview = vi.fn();
1006
+ const fetchMock = stubProjectFetch(source, {
1007
+ mutated: false,
1008
+ scriptText: null,
1009
+ before: source,
1010
+ after: source,
1011
+ });
1012
+ const { groupMove, unmount } = renderTimelineEditingHook({
1013
+ timelineElements: [a, b],
1014
+ iframe,
1015
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1016
+ projectId: "p1",
1017
+ writeProjectFile,
1018
+ recordEdit: vi.fn(async () => {}),
1019
+ reloadPreview,
1020
+ });
1021
+
1022
+ await act(async () => {
1023
+ await groupMove([
1024
+ { element: a, start: a.start, track: 2 },
1025
+ { element: b, start: 1.5, track: b.track },
1026
+ ]);
1027
+ await flushAsyncWork();
1028
+ });
1029
+
1030
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
1031
+ // The time-shifted clip still goes through the GSAP shift endpoint...
1032
+ expect(
1033
+ fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
1034
+ ).toBe(true);
1035
+ // ...and with no rewritten scriptText the sync escalates to one full reload.
1036
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
1037
+
1038
+ unmount();
1039
+ });
1040
+
870
1041
  it("matches the single-clip move output when a group move contains one clip", async () => {
871
1042
  const source = '<div id="clip" data-start="0" data-duration="1"></div>';
872
1043
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
@@ -949,79 +1120,56 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
949
1120
  return { iframe, clip, hook, writeError };
950
1121
  }
951
1122
 
952
- it("rolls back the store duration and live root when a move persist fails", async () => {
953
- const { iframe, clip, hook, writeError } = setupFailedPersist();
954
-
1123
+ /**
1124
+ * Run a persist that must reject with the harness's writeError, then assert
1125
+ * the store duration AND the live root rolled back to the pre-edit 4s —
1126
+ * the shared act/assert core of the four failed-persist tests below.
1127
+ */
1128
+ async function expectPersistRollback(
1129
+ ctx: ReturnType<typeof setupFailedPersist>,
1130
+ run: () => Promise<unknown>,
1131
+ ): Promise<void> {
955
1132
  let rejection: unknown;
956
1133
  await act(async () => {
957
- // Move past the end: the optimistic sync grows the readout to 5s.
958
- await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
1134
+ await run().catch((error) => {
959
1135
  rejection = error;
960
1136
  });
961
1137
  await flushAsyncWork();
962
1138
  });
963
-
964
- expect(rejection).toBe(writeError);
1139
+ expect(rejection).toBe(ctx.writeError);
965
1140
  expect(usePlayerStore.getState().duration).toBe(4);
966
- expect(rootDurationAttr(iframe)).toBe("4");
1141
+ expect(rootDurationAttr(ctx.iframe)).toBe("4");
1142
+ }
967
1143
 
968
- hook.unmount();
1144
+ it("rolls back the store duration and live root when a move persist fails", async () => {
1145
+ const ctx = setupFailedPersist();
1146
+ // Move past the end: the optimistic sync grows the readout to 5s.
1147
+ await expectPersistRollback(ctx, () =>
1148
+ ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }),
1149
+ );
1150
+ ctx.hook.unmount();
969
1151
  });
970
1152
 
971
1153
  it("rolls back the store duration and live root when a resize persist fails", async () => {
972
- const { iframe, clip, hook, writeError } = setupFailedPersist();
973
-
974
- let rejection: unknown;
975
- await act(async () => {
976
- await hook
977
- .resize(clip, { start: 0, duration: 6, playbackStart: undefined })
978
- .catch((error) => {
979
- rejection = error;
980
- });
981
- await flushAsyncWork();
982
- });
983
-
984
- expect(rejection).toBe(writeError);
985
- expect(usePlayerStore.getState().duration).toBe(4);
986
- expect(rootDurationAttr(iframe)).toBe("4");
987
-
988
- hook.unmount();
1154
+ const ctx = setupFailedPersist();
1155
+ await expectPersistRollback(ctx, () =>
1156
+ ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }),
1157
+ );
1158
+ ctx.hook.unmount();
989
1159
  });
990
1160
 
991
1161
  it("rolls back the store duration and live root when a group move persist fails", async () => {
992
- const { iframe, clip, hook, writeError } = setupFailedPersist();
993
-
994
- let rejection: unknown;
995
- await act(async () => {
996
- await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
997
- rejection = error;
998
- });
999
- await flushAsyncWork();
1000
- });
1001
-
1002
- expect(rejection).toBe(writeError);
1003
- expect(usePlayerStore.getState().duration).toBe(4);
1004
- expect(rootDurationAttr(iframe)).toBe("4");
1005
-
1006
- hook.unmount();
1162
+ const ctx = setupFailedPersist();
1163
+ await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }]));
1164
+ ctx.hook.unmount();
1007
1165
  });
1008
1166
 
1009
1167
  it("rolls back the store duration and live root when a group resize persist fails", async () => {
1010
- const { iframe, clip, hook, writeError } = setupFailedPersist();
1011
-
1012
- let rejection: unknown;
1013
- await act(async () => {
1014
- await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
1015
- rejection = error;
1016
- });
1017
- await flushAsyncWork();
1018
- });
1019
-
1020
- expect(rejection).toBe(writeError);
1021
- expect(usePlayerStore.getState().duration).toBe(4);
1022
- expect(rootDurationAttr(iframe)).toBe("4");
1023
-
1024
- hook.unmount();
1168
+ const ctx = setupFailedPersist();
1169
+ await expectPersistRollback(ctx, () =>
1170
+ ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]),
1171
+ );
1172
+ ctx.hook.unmount();
1025
1173
  });
1026
1174
 
1027
1175
  it("rolls back the store duration and live root when a delete persist fails", async () => {