@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
@@ -0,0 +1,440 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { usePlayerStore, type TimelineElement } from "../../player";
6
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
7
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
8
+ import { useElementLifecycleOps, zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
9
+ import {
10
+ useCanvasZOrderTimelineMirror,
11
+ type MirrorZOrderInput,
12
+ } from "./useCanvasZOrderTimelineMirror";
13
+ import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
14
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
15
+ import {
16
+ buildEditHistoryEntry,
17
+ createEmptyEditHistory,
18
+ pushEditHistoryEntry,
19
+ type EditHistoryState,
20
+ } from "../../utils/editHistory";
21
+
22
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23
+
24
+ afterEach(() => {
25
+ document.body.innerHTML = "";
26
+ // Wrapped in act: mounted harnesses subscribe to the store via
27
+ // useExpandedTimelineElements, so the reset re-renders them.
28
+ act(() => usePlayerStore.getState().setElements([]));
29
+ });
30
+
31
+ /** Set the store elements inside act (mounted harnesses re-render on it). */
32
+ function setStoreElements(elements: TimelineElement[]): void {
33
+ act(() => usePlayerStore.getState().setElements(elements));
34
+ }
35
+
36
+ // Store keys follow buildTimelineElementKey's `<sourceFile>#<domId>` branch —
37
+ // the same shape deriveTimelineStoreKey produces for reorder entries.
38
+ function storeEl(domId: string, track: number, start: number, duration: number): TimelineElement {
39
+ return {
40
+ id: domId,
41
+ key: `index.html#${domId}`,
42
+ tag: "video",
43
+ start,
44
+ duration,
45
+ track,
46
+ domId,
47
+ };
48
+ }
49
+
50
+ type ReorderEntries = Array<{
51
+ element: HTMLElement;
52
+ zIndex: number;
53
+ id?: string;
54
+ selector?: string;
55
+ sourceFile: string;
56
+ key?: string;
57
+ }>;
58
+
59
+ interface HarnessApi {
60
+ commitZ: (entries: ReorderEntries, coalesceKey: string, action: string) => Promise<unknown>;
61
+ mirror: (input: MirrorZOrderInput) => Promise<boolean>;
62
+ }
63
+
64
+ /**
65
+ * Mount the REAL wiring pair PreviewOverlays composes — handleDomZIndexReorderCommit
66
+ * (z persist) + useCanvasZOrderTimelineMirror (lane mirror) — over a shared,
67
+ * real editHistory reducer so the undo-fold assertion exercises the actual
68
+ * pushEditHistoryEntry coalescing:
69
+ *
70
+ * - the z sink mimics commitDomEditPatchBatches' recordEdit call verbatim
71
+ * (kind "manual", options.coalesceKey — see useDomEditCommits.ts), and
72
+ * - the move sink mimics persistTimelineBatchEdit → saveProjectFilesWithHistory
73
+ * (kind "timeline", the coalesceKey forwarded through onMoveElements — see
74
+ * timelineEditingHelpers.ts / studioFileHistory.ts),
75
+ *
76
+ * with a deterministic clock inside the reducer's 300ms coalesce window.
77
+ */
78
+ function mountMirrorHarness(history: {
79
+ state: EditHistoryState;
80
+ now: () => number;
81
+ fileContent: { current: string };
82
+ moveCoalesceKeys: string[];
83
+ }) {
84
+ const record = (
85
+ label: string,
86
+ kind: "manual" | "timeline",
87
+ coalesceKey: string,
88
+ coalesceMs: number | undefined,
89
+ after: string,
90
+ ) => {
91
+ const entry = buildEditHistoryEntry({
92
+ id: `e-${history.now()}`,
93
+ projectId: "p",
94
+ label,
95
+ kind,
96
+ coalesceKey,
97
+ coalesceMs,
98
+ now: history.now(),
99
+ files: { "index.html": { before: history.fileContent.current, after } },
100
+ });
101
+ history.fileContent.current = after;
102
+ history.state = pushEditHistoryEntry(history.state, entry);
103
+ };
104
+
105
+ const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
106
+ _edits,
107
+ coalesceKey,
108
+ _operation,
109
+ coalesceMs,
110
+ ) => {
111
+ history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
112
+ record("Move timeline clips", "timeline", coalesceKey ?? "<none>", coalesceMs, "C-move");
113
+ };
114
+
115
+ const api: Partial<HarnessApi> = {};
116
+ function Harness() {
117
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
118
+ makeLifecycleOpsParams({
119
+ commitDomEditPatchBatches: async (_batches, options) => {
120
+ record(options.label, "manual", options.coalesceKey, options.coalesceMs, "B-z");
121
+ return { durable: true, allMatched: true, changed: true };
122
+ },
123
+ }),
124
+ );
125
+ api.commitZ = handleDomZIndexReorderCommit;
126
+ api.mirror = useCanvasZOrderTimelineMirror();
127
+ return null;
128
+ }
129
+ mountReactHarness(
130
+ <TimelineEditProvider value={{ onMoveElements }}>
131
+ <Harness />
132
+ </TimelineEditProvider>,
133
+ );
134
+ return api as HarnessApi;
135
+ }
136
+
137
+ function makeHistory() {
138
+ let tick = 1000;
139
+ return {
140
+ state: createEmptyEditHistory(),
141
+ // Deterministic clock: consecutive records land 400ms apart — PAST the
142
+ // reducer's default 300ms coalesce window, as in the live flow where the
143
+ // mirror is dispatched only after the z persist's server round-trip
144
+ // resolves (real network latency exceeds 300ms). The fold must therefore
145
+ // ride the gesture's explicit coalesceMs window, not the default.
146
+ now: () => (tick += 400),
147
+ fileContent: { current: "A-original" },
148
+ moveCoalesceKeys: [] as string[],
149
+ };
150
+ }
151
+
152
+ function mountMirrorOnlyHarness(
153
+ onMoveElements: TimelineEditCallbacks["onMoveElements"],
154
+ ): Pick<HarnessApi, "mirror"> {
155
+ const api: Partial<HarnessApi> = {};
156
+ function Harness() {
157
+ api.mirror = useCanvasZOrderTimelineMirror();
158
+ return null;
159
+ }
160
+ mountReactHarness(
161
+ <TimelineEditProvider value={{ onMoveElements }}>
162
+ <Harness />
163
+ </TimelineEditProvider>,
164
+ );
165
+ const mirror = api.mirror;
166
+ if (!mirror) throw new Error("mirror harness failed to mount");
167
+ return { mirror };
168
+ }
169
+
170
+ function domTarget(id: string): HTMLElement {
171
+ const el = document.createElement("div");
172
+ el.id = id;
173
+ document.body.appendChild(el);
174
+ return el;
175
+ }
176
+
177
+ describe("useCanvasZOrderTimelineMirror", () => {
178
+ it("folds the z write and the mirrored lane write into ONE undo entry (shared coalesce key)", async () => {
179
+ // Timeline: t on lane 2, b on lane 1 (the crossed neighbor), a on lane 0
180
+ // free over t's span → bring-forward mirrors to a kind:"move" onto lane 0.
181
+ setStoreElements([storeEl("a", 0, 20, 5), storeEl("b", 1, 0, 10), storeEl("t", 2, 0, 10)]);
182
+ const history = makeHistory();
183
+ const api = mountMirrorHarness(history);
184
+
185
+ const target = domTarget("t");
186
+ const entries: ReorderEntries = [
187
+ { element: target, zIndex: 7, id: "t", sourceFile: "index.html", key: "index.html#t" },
188
+ ];
189
+ const coalesceKey = zReorderCoalesceKey(entries, "bring-forward");
190
+ // Gesture-unique key: action + ids + a per-call gesture sequence, so two
191
+ // SEPARATE actions on the same selection never share a key (see the
192
+ // two-gestures test below) while this gesture's two records do.
193
+ expect(coalesceKey).toMatch(/^z-reorder:bring-forward:t:g\d+$/);
194
+
195
+ await act(async () => {
196
+ // The PreviewOverlays wiring: z commit first, mirror after it resolves,
197
+ // BOTH with the same key.
198
+ await api.commitZ(entries, coalesceKey, "bring-forward");
199
+ const mirrored = await api.mirror({
200
+ selectionKey: "index.html#t",
201
+ action: "bring-forward",
202
+ crossed: domTarget("b"),
203
+ sourceFile: "index.html",
204
+ coalesceKey,
205
+ });
206
+ expect(mirrored).toBe(true);
207
+ });
208
+
209
+ // The move persist received the EXACT z coalesce key…
210
+ expect(history.moveCoalesceKeys).toEqual([coalesceKey]);
211
+ // …and the real reducer folded the two records into one undo entry spanning
212
+ // pre-z "before" → post-move "after". One Cmd+Z reverts both writes.
213
+ expect(history.state.undo).toHaveLength(1);
214
+ expect(history.state.undo[0].files["index.html"]).toMatchObject({
215
+ before: "A-original",
216
+ after: "C-move",
217
+ });
218
+ // Timeline UI reflects the lane change without a reload: optimistic store update.
219
+ const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
220
+ expect(t?.track).toBe(0);
221
+ });
222
+
223
+ it("two SEPARATE gestures on the same selection produce TWO undo entries (distinct keys)", async () => {
224
+ // Two consecutive gestures on the same element: each mints its own coalesce
225
+ // key (gesture sequence), so even an unbounded per-gesture window must never
226
+ // merge distinct user actions into one undo step.
227
+ setStoreElements([storeEl("t", 0, 0, 10), storeEl("b", 1, 0, 10)]);
228
+ const history = makeHistory();
229
+ const api = mountMirrorHarness(history);
230
+
231
+ const target = domTarget("t");
232
+ const entries: ReorderEntries = [
233
+ { element: target, zIndex: 7, id: "t", sourceFile: "index.html", key: "index.html#t" },
234
+ ];
235
+
236
+ // Identical action + identical selection still mints a fresh key per call.
237
+ expect(zReorderCoalesceKey(entries, "send-backward")).not.toBe(
238
+ zReorderCoalesceKey(entries, "send-backward"),
239
+ );
240
+
241
+ const keys: string[] = [];
242
+ // Gesture 1: t (top) sent backward past b → back-to-back, no free lane in
243
+ // the bounded interval → insert immediately below b (t and b swap lanes).
244
+ // Gesture 2: t brought forward past b → insert immediately above b (swap
245
+ // back). Both gestures mirror (lane move persists), so each records a
246
+ // z+move pair.
247
+ const gestures = [
248
+ { action: "send-backward" as const, crossedId: "b" },
249
+ { action: "bring-forward" as const, crossedId: "b" },
250
+ ];
251
+ for (const { action, crossedId } of gestures) {
252
+ const coalesceKey = zReorderCoalesceKey(entries, action);
253
+ keys.push(coalesceKey);
254
+ await act(async () => {
255
+ await api.commitZ(entries, coalesceKey, action);
256
+ const mirrored = await api.mirror({
257
+ selectionKey: "index.html#t",
258
+ action,
259
+ crossed: domTarget(crossedId),
260
+ sourceFile: "index.html",
261
+ coalesceKey,
262
+ });
263
+ expect(mirrored).toBe(true);
264
+ });
265
+ }
266
+
267
+ expect(keys[0]).not.toBe(keys[1]); // fresh key per gesture
268
+ // Each gesture folded its own z+move pair, but the two gestures stayed apart.
269
+ expect(history.state.undo).toHaveLength(2);
270
+ expect(history.moveCoalesceKeys).toEqual(keys);
271
+ });
272
+
273
+ it("z-only actions leave the timeline untouched (resolver null → single z undo entry)", async () => {
274
+ // t has NO overlapping neighbor above → bring-forward has no lane mirror.
275
+ setStoreElements([storeEl("a", 0, 20, 5), storeEl("t", 1, 0, 10)]);
276
+ const history = makeHistory();
277
+ const api = mountMirrorHarness(history);
278
+
279
+ const entries: ReorderEntries = [
280
+ {
281
+ element: domTarget("t"),
282
+ zIndex: 3,
283
+ id: "t",
284
+ sourceFile: "index.html",
285
+ key: "index.html#t",
286
+ },
287
+ ];
288
+ const coalesceKey = zReorderCoalesceKey(entries, "bring-forward");
289
+ await act(async () => {
290
+ await api.commitZ(entries, coalesceKey, "bring-forward");
291
+ const mirrored = await api.mirror({
292
+ selectionKey: "index.html#t",
293
+ action: "bring-forward",
294
+ crossed: null,
295
+ sourceFile: "index.html",
296
+ coalesceKey,
297
+ });
298
+ expect(mirrored).toBe(false);
299
+ });
300
+
301
+ expect(history.moveCoalesceKeys).toEqual([]); // no lane persist dispatched
302
+ expect(history.state.undo).toHaveLength(1); // just the z entry
303
+ const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
304
+ expect(t?.track).toBe(1); // lane unchanged
305
+ });
306
+
307
+ it("elements that are not timeline clips resolve false without touching the persist path", async () => {
308
+ setStoreElements([storeEl("a", 0, 0, 10)]);
309
+ const history = makeHistory();
310
+ const api = mountMirrorHarness(history);
311
+ const mirrored = await act(async () =>
312
+ api.mirror({
313
+ selectionKey: undefined, // canvas-only decoration: no timeline key
314
+ action: "send-to-back",
315
+ crossed: null,
316
+ sourceFile: "index.html",
317
+ coalesceKey: "z-reorder:send-to-back:x",
318
+ }),
319
+ );
320
+ expect(mirrored).toBe(false);
321
+ expect(history.moveCoalesceKeys).toEqual([]);
322
+ });
323
+
324
+ it("maps the crossed neighbor to its timeline key and rebases expanded sub-comp children", async () => {
325
+ // t is an expanded sub-comp child (expandedParentStart 5, absolute start 5):
326
+ // the mirror must forward its persist in LOCAL time (start 0), the same
327
+ // rebase a timeline lane drag applies (forwardRebasedTimelineMoveElements).
328
+ setStoreElements([
329
+ {
330
+ ...storeEl("a", 0, 25, 5),
331
+ sourceFile: "sub.html",
332
+ key: "sub.html#a",
333
+ expandedParentStart: 5,
334
+ },
335
+ {
336
+ ...storeEl("b", 1, 5, 10),
337
+ sourceFile: "sub.html",
338
+ key: "sub.html#b",
339
+ expandedParentStart: 5,
340
+ },
341
+ {
342
+ ...storeEl("t", 2, 5, 10),
343
+ sourceFile: "sub.html",
344
+ key: "sub.html#t",
345
+ expandedParentStart: 5,
346
+ },
347
+ ]);
348
+ const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> =
349
+ [];
350
+ const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => {
351
+ edits.push(...batch);
352
+ };
353
+ const { mirror } = mountMirrorOnlyHarness(onMoveElements);
354
+
355
+ const mirrored = await act(async () =>
356
+ mirror({
357
+ selectionKey: "sub.html#t",
358
+ action: "bring-forward",
359
+ // The crossed sibling maps to sub.html#b via its DOM id + sourceFile —
360
+ // the same derivation reorder entries use (deriveTimelineStoreKey).
361
+ crossed: domTarget("b"),
362
+ sourceFile: "sub.html",
363
+ coalesceKey: "z-reorder:bring-forward:t",
364
+ }),
365
+ );
366
+ expect(mirrored).toBe(true);
367
+ expect(edits).toHaveLength(1);
368
+ // Rebased to sub-comp local coords: absolute 5 − parent start 5 = 0.
369
+ expect(edits[0].element.start).toBe(0);
370
+ expect(edits[0].updates).toMatchObject({ start: 0, track: 0 });
371
+ });
372
+
373
+ it("maps a cross-file duplicate selector to the source-scoped crossed occurrence", async () => {
374
+ setStoreElements([
375
+ {
376
+ id: "scene.html:.sub:0",
377
+ key: "scene.html:.sub:0",
378
+ tag: "div",
379
+ start: 20,
380
+ duration: 5,
381
+ track: 0,
382
+ selector: ".sub",
383
+ selectorIndex: 0,
384
+ sourceFile: "scene.html",
385
+ },
386
+ {
387
+ id: "scene.html:.sub:1",
388
+ key: "scene.html:.sub:1",
389
+ tag: "div",
390
+ start: 0,
391
+ duration: 10,
392
+ track: 1,
393
+ selector: ".sub",
394
+ selectorIndex: 1,
395
+ sourceFile: "scene.html",
396
+ },
397
+ {
398
+ ...storeEl("t", 2, 0, 10),
399
+ key: "scene.html#t",
400
+ sourceFile: "scene.html",
401
+ },
402
+ ]);
403
+ const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> =
404
+ [];
405
+ const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => {
406
+ edits.push(...batch);
407
+ };
408
+ const { mirror } = mountMirrorOnlyHarness(onMoveElements);
409
+
410
+ // This root-file duplicate precedes the scene nodes in the flattened preview
411
+ // DOM. It must not offset scene.html's selector indices.
412
+ const rootDuplicate = document.createElement("div");
413
+ rootDuplicate.className = "sub";
414
+ document.body.appendChild(rootDuplicate);
415
+ const scene = document.createElement("div");
416
+ scene.setAttribute("data-composition-id", "scene");
417
+ scene.setAttribute("data-composition-file", "scene.html");
418
+ document.body.appendChild(scene);
419
+ const first = document.createElement("div");
420
+ first.className = "sub";
421
+ scene.appendChild(first);
422
+ const crossed = document.createElement("div");
423
+ crossed.className = "sub";
424
+ scene.appendChild(crossed);
425
+
426
+ const mirrored = await act(async () =>
427
+ mirror({
428
+ selectionKey: "scene.html#t",
429
+ action: "bring-forward",
430
+ crossed,
431
+ sourceFile: "scene.html",
432
+ coalesceKey: "z-reorder:bring-forward:t",
433
+ }),
434
+ );
435
+
436
+ expect(mirrored).toBe(true);
437
+ expect(edits).toHaveLength(1);
438
+ expect(edits[0].updates.track).toBe(0);
439
+ });
440
+ });
@@ -0,0 +1,191 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { usePlayerStore } from "../../player";
3
+ import { useExpandedTimelineElements } from "../../player/hooks/useExpandedTimelineElements";
4
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
5
+ import {
6
+ displayTrackOrder,
7
+ resolveRepositionLaneMove,
8
+ resolveZMirrorLaneMove,
9
+ type ZMirrorAction,
10
+ type ZMirrorLaneMove,
11
+ } from "../../player/components/timelineZMirror";
12
+ import type { TimelineElement } from "../../player/store/playerStore";
13
+ import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragCommit";
14
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
15
+ import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom";
16
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
17
+ import { forwardRebasedTimelineMoveElements } from "./TimelinePane";
18
+
19
+ export interface MirrorZOrderInput {
20
+ /** Timeline store key of the element the menu acted on (entry.key), if any. */
21
+ selectionKey: string | undefined;
22
+ action: ZMirrorAction;
23
+ /** Sibling a forward/backward step moved past (from resolveCrossedNeighbor). */
24
+ crossed: HTMLElement | null;
25
+ /** Source file of the selection — siblings share it (same document). */
26
+ sourceFile: string;
27
+ /** The z persist's coalesce key (zReorderCoalesceKey) — REQUIRED so the lane
28
+ * write folds into the same undo entry as the z write. */
29
+ coalesceKey: string;
30
+ }
31
+
32
+ /**
33
+ * Mirror a successful canvas z-order menu action into a timeline LANE move.
34
+ *
35
+ * The caller (PreviewOverlays) invokes the returned callback AFTER the z commit
36
+ * resolved — serializing the two same-file writes, exactly like the lane-drag's
37
+ * move→z ordering (see persistMoveEdits' doc) — and with the SAME coalesce key
38
+ * the z persist recorded, so editHistory folds both records into one undo entry.
39
+ * Because the z round-trip puts a real-network gap between the two records, the
40
+ * lane persist carries an unbounded per-gesture coalesce window (see
41
+ * commitZMirrorLaneMove) — the shared key is unique per gesture
42
+ * (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
43
+ *
44
+ * Element source: `useExpandedTimelineElements()` — the same expanded display
45
+ * set the Timeline renders and the resolver expects (post-normalizeToZones
46
+ * lanes, expanded sub-comp children on their synthetic rows). No new expansion
47
+ * is built here.
48
+ *
49
+ * The mirror persists through the SAME machinery as a timeline lane drag
50
+ * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded
51
+ * children rebased to local coords via forwardRebasedTimelineMoveElements) —
52
+ * optimistic store update + rollback included, so the timeline reflects the
53
+ * lane change without a reload. The deps below deliberately OMIT
54
+ * `readZIndex`/`onStackingPatches`: the lane→z stacking sync
55
+ * (syncStackingForEdit) must not fire and recompute the z values the user just
56
+ * set — commitZMirrorLaneMove never calls it, and without these deps it would
57
+ * no-op even if called.
58
+ *
59
+ * Resolves `true` when a lane move persisted, `false` for z-only actions (no
60
+ * timeline mirror applies) or a rolled-back persist.
61
+ */
62
+ export function useCanvasZOrderTimelineMirror(): (input: MirrorZOrderInput) => Promise<boolean> {
63
+ const commitMirrorMove = useMirrorLaneMoveCommit();
64
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
65
+
66
+ return useCallback(
67
+ (input: MirrorZOrderInput) =>
68
+ commitMirrorMove(input.selectionKey, input.coalesceKey, (element, els) => {
69
+ // Map the crossed neighbor to its timeline key the same way z-reorder
70
+ // entries get theirs (siblingZIndexEntry): DOM id, else stable selector
71
+ // WITH its selector index, scoped to the selection's source file. The
72
+ // index matters: class selectors are duplicated across clips (.sub),
73
+ // and a key derived without it resolves to occurrence 0 — a DIFFERENT
74
+ // clip — silently mirroring against the wrong neighbor.
75
+ const crossedSelector = input.crossed ? buildStableSelector(input.crossed) : undefined;
76
+ const crossedKey = input.crossed
77
+ ? deriveTimelineStoreKey({
78
+ domId: input.crossed.id || undefined,
79
+ selector: crossedSelector,
80
+ selectorIndex: getSelectorIndex(
81
+ input.crossed.ownerDocument,
82
+ input.crossed,
83
+ crossedSelector,
84
+ input.sourceFile,
85
+ activeCompPath,
86
+ ),
87
+ sourceFile: input.sourceFile,
88
+ })
89
+ : null;
90
+ return resolveZMirrorLaneMove({ action: input.action, element, elements: els, crossedKey });
91
+ }),
92
+ [commitMirrorMove, activeCompPath],
93
+ );
94
+ }
95
+
96
+ export interface LayerReorderMirrorInput {
97
+ /** Timeline store key of the dragged layer (entry.key), if any. */
98
+ selectionKey: string | undefined;
99
+ /** Reordered sibling keys in DESIRED render order, bottom→top (null = no
100
+ * timeline presence) — see resolveRepositionLaneMove. */
101
+ desiredOrderKeys: ReadonlyArray<string | null>;
102
+ /** The z persist's coalesce key — REQUIRED so the lane write folds into the
103
+ * same undo entry as the z write. */
104
+ coalesceKey: string;
105
+ }
106
+
107
+ /**
108
+ * Mirror a Layers-panel drag (arbitrary reposition, possibly jumping several
109
+ * siblings) into a timeline lane move — the "equal jump". Identical plumbing,
110
+ * serialization, and undo-fold contract as {@link useCanvasZOrderTimelineMirror};
111
+ * only the resolver differs (resolveRepositionLaneMove's between-new-neighbors
112
+ * rule instead of the menu's four fixed actions).
113
+ */
114
+ export function useLayerReorderTimelineMirror(): (
115
+ input: LayerReorderMirrorInput,
116
+ ) => Promise<boolean> {
117
+ const commitMirrorMove = useMirrorLaneMoveCommit();
118
+
119
+ return useCallback(
120
+ (input: LayerReorderMirrorInput) =>
121
+ commitMirrorMove(input.selectionKey, input.coalesceKey, (element, els) =>
122
+ resolveRepositionLaneMove({
123
+ element,
124
+ elements: els,
125
+ desiredOrderKeys: input.desiredOrderKeys,
126
+ }),
127
+ ),
128
+ [commitMirrorMove],
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Shared commit plumbing for both mirrors: resolve the selection key against
134
+ * the expanded display set, run the caller's resolver, and persist the lane
135
+ * move through commitZMirrorLaneMove with the same deps + undo-fold contract.
136
+ * Resolves `true` when a lane move persisted, `false` for z-only actions (no
137
+ * timeline mirror applies) or a rolled-back persist.
138
+ */
139
+ function useMirrorLaneMoveCommit(): (
140
+ selectionKey: string | undefined,
141
+ coalesceKey: string,
142
+ resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
143
+ ) => Promise<boolean> {
144
+ const elements = useExpandedTimelineElements();
145
+ const elementsRef = useRef(elements);
146
+ elementsRef.current = elements;
147
+ const { onMoveElements } = useTimelineEditContextOptional();
148
+
149
+ return useCallback(
150
+ (selectionKey, coalesceKey, resolveMove) => {
151
+ const els = elementsRef.current;
152
+ const element = selectionKey ? els.find((e) => (e.key ?? e.id) === selectionKey) : undefined;
153
+ // Not a timeline clip (canvas-only decoration) → z-only action, unchanged.
154
+ if (!element) return Promise.resolve(false);
155
+
156
+ const move = resolveMove(element, els);
157
+ if (!move) return Promise.resolve(false);
158
+
159
+ return commitZMirrorLaneMove(
160
+ element,
161
+ move,
162
+ {
163
+ elements: els,
164
+ trackOrder: displayTrackOrder(els),
165
+ updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates),
166
+ onMoveElements: onMoveElements
167
+ ? (edits, coalesceKey2, operation, coalesceMs) =>
168
+ forwardRebasedTimelineMoveElements(
169
+ edits,
170
+ coalesceKey2,
171
+ operation,
172
+ onMoveElements,
173
+ coalesceMs,
174
+ )
175
+ : undefined,
176
+ // NO readZIndex / onStackingPatches: see the hook doc — the lane→z
177
+ // stacking sync must not re-trigger and fight the just-set z values.
178
+ },
179
+ coalesceKey,
180
+ // Unbounded fold window: this record lands only AFTER the z persist's
181
+ // server round-trip resolved, so the gap between the gesture's two
182
+ // records exceeds editHistory's 300ms default under real latency and
183
+ // the fold would silently split into two undo entries. The shared key
184
+ // is unique per gesture (zReorderCoalesceKey's gesture seq), so the
185
+ // unbounded window can never merge two distinct user actions.
186
+ Number.POSITIVE_INFINITY,
187
+ );
188
+ },
189
+ [onMoveElements],
190
+ );
191
+ }
@@ -22,6 +22,7 @@ export interface TimelineEditCallbackDeps {
22
22
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
23
23
  coalesceKey?: string,
24
24
  operation?: TimelineMoveOperation,
25
+ coalesceMs?: number,
25
26
  ) => Promise<void> | void;
26
27
  handleTimelineElementResize: (
27
28
  element: TimelineElement,