@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,176 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { runLaneZGesture, runZLaneGesture } from "./zLaneGesture";
3
+
4
+ const durable = { durable: true, allMatched: true, changed: true };
5
+
6
+ function deferred<T>() {
7
+ let resolve!: (v: T) => void;
8
+ let reject!: (e: unknown) => void;
9
+ const promise = new Promise<T>((res, rej) => {
10
+ resolve = res;
11
+ reject = rej;
12
+ });
13
+ return { promise, resolve, reject };
14
+ }
15
+
16
+ describe("runZLaneGesture", () => {
17
+ it("runs the mirror after a durable z commit and resolves its result", async () => {
18
+ const order: string[] = [];
19
+ const result = await runZLaneGesture({
20
+ commitZ: async () => {
21
+ order.push("z");
22
+ return durable;
23
+ },
24
+ mirror: async () => {
25
+ order.push("mirror");
26
+ return true;
27
+ },
28
+ });
29
+ expect(result).toBe(true);
30
+ expect(order).toEqual(["z", "mirror"]);
31
+ });
32
+
33
+ it("skips the mirror when the z commit reports unmatched targets", async () => {
34
+ const mirror = vi.fn(async () => true);
35
+ const result = await runZLaneGesture({
36
+ commitZ: async () => ({ durable: false, allMatched: false, changed: true }),
37
+ mirror,
38
+ });
39
+ expect(result).toBe(false);
40
+ expect(mirror).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("still mirrors on a void resolution (empty-entries commit path)", async () => {
44
+ const mirror = vi.fn(async () => true);
45
+ await runZLaneGesture({ commitZ: async () => undefined, mirror });
46
+ expect(mirror).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it("serializes gestures: B's z phase waits for A's mirror phase", async () => {
50
+ const order: string[] = [];
51
+ const aMirrorGate = deferred<void>();
52
+
53
+ const a = runZLaneGesture({
54
+ commitZ: async () => {
55
+ order.push("A:z");
56
+ return durable;
57
+ },
58
+ mirror: async () => {
59
+ await aMirrorGate.promise;
60
+ order.push("A:mirror");
61
+ return true;
62
+ },
63
+ });
64
+ const b = runZLaneGesture({
65
+ commitZ: async () => {
66
+ order.push("B:z");
67
+ return durable;
68
+ },
69
+ mirror: async () => {
70
+ order.push("B:mirror");
71
+ return true;
72
+ },
73
+ });
74
+
75
+ // Give B every chance to start early — it must not.
76
+ await new Promise((r) => setTimeout(r, 10));
77
+ expect(order).toEqual(["A:z"]);
78
+
79
+ aMirrorGate.resolve();
80
+ await Promise.all([a, b]);
81
+ expect(order).toEqual(["A:z", "A:mirror", "B:z", "B:mirror"]);
82
+ });
83
+
84
+ it("holds a lane→z gesture until an overlapping z→lane gesture fully settles", async () => {
85
+ const order: string[] = [];
86
+ const mirrorGate = deferred<void>();
87
+ const zFirst = runZLaneGesture({
88
+ commitZ: async () => {
89
+ order.push("z-first:z");
90
+ return durable;
91
+ },
92
+ mirror: async () => {
93
+ order.push("z-first:lane-start");
94
+ await mirrorGate.promise;
95
+ order.push("z-first:lane-end");
96
+ return true;
97
+ },
98
+ });
99
+ const laneFirst = runLaneZGesture({
100
+ commitLane: async () => {
101
+ order.push("lane-first:lane");
102
+ return true;
103
+ },
104
+ commitZ: async () => {
105
+ order.push("lane-first:z");
106
+ },
107
+ });
108
+
109
+ await Promise.resolve();
110
+ expect(order).toEqual(["z-first:z", "z-first:lane-start"]);
111
+ mirrorGate.resolve();
112
+ await Promise.all([zFirst, laneFirst]);
113
+ expect(order).toEqual([
114
+ "z-first:z",
115
+ "z-first:lane-start",
116
+ "z-first:lane-end",
117
+ "lane-first:lane",
118
+ "lane-first:z",
119
+ ]);
120
+ });
121
+
122
+ it("holds a z→lane gesture until an overlapping lane→z gesture fully settles", async () => {
123
+ const order: string[] = [];
124
+ const zGate = deferred<void>();
125
+ const laneFirst = runLaneZGesture({
126
+ commitLane: async () => {
127
+ order.push("lane-first:lane");
128
+ return true;
129
+ },
130
+ commitZ: async () => {
131
+ order.push("lane-first:z-start");
132
+ await zGate.promise;
133
+ order.push("lane-first:z-end");
134
+ },
135
+ });
136
+ const zFirst = runZLaneGesture({
137
+ commitZ: async () => {
138
+ order.push("z-first:z");
139
+ return durable;
140
+ },
141
+ mirror: async () => {
142
+ order.push("z-first:lane");
143
+ return true;
144
+ },
145
+ });
146
+
147
+ await Promise.resolve();
148
+ expect(order).toEqual(["lane-first:lane", "lane-first:z-start"]);
149
+ zGate.resolve();
150
+ await Promise.all([laneFirst, zFirst]);
151
+ expect(order).toEqual([
152
+ "lane-first:lane",
153
+ "lane-first:z-start",
154
+ "lane-first:z-end",
155
+ "z-first:z",
156
+ "z-first:lane",
157
+ ]);
158
+ });
159
+
160
+ it("a failed gesture rejects its caller but never wedges the queue", async () => {
161
+ const boom = new Error("z failed");
162
+ const failed = runZLaneGesture({
163
+ commitZ: async () => {
164
+ throw boom;
165
+ },
166
+ mirror: async () => true,
167
+ });
168
+ await expect(failed).rejects.toBe(boom);
169
+
170
+ const next = await runZLaneGesture({
171
+ commitZ: async () => durable,
172
+ mirror: async () => true,
173
+ });
174
+ expect(next).toBe(true);
175
+ });
176
+ });
@@ -0,0 +1,77 @@
1
+ import type { DomEditPatchBatchesResult } from "../../hooks/domEditCommitTypes";
2
+
3
+ /**
4
+ * Run one COMPLETE z→lane gesture — the z-index persist followed by its
5
+ * timeline lane mirror — as a single serialized transaction.
6
+ *
7
+ * Why a queue: the two phases persist through DIFFERENT pipelines (the z patch
8
+ * rides the DOM-edit save queue, the lane move rides the timeline/SDK move
9
+ * path). Each gesture orders its own phases by awaiting the z persist, but
10
+ * without cross-gesture serialization a second rapid gesture can land between
11
+ * the first gesture's phases and the interleaved file writes can clobber each
12
+ * other. Canvas z→lane and timeline lane→z gestures both chain through this
13
+ * single module-level tail, so gesture B cannot start until gesture A settled.
14
+ *
15
+ * Why the durability gate: a resolved z commit is not necessarily durable —
16
+ * when the server cannot match a patch target, commitDomEditPatchBatches
17
+ * resolves with `durable: false` (after scheduling a reload to
18
+ * reconverge). Mirroring a lane move onto a z state that disk never held
19
+ * would desync track order from what actually paints, so the mirror phase is
20
+ * skipped and the gesture resolves `false`.
21
+ *
22
+ * Failures never wedge the queue: a rejected gesture propagates to ITS caller
23
+ * while the tail continues for the next gesture.
24
+ */
25
+ let gestureTail: Promise<void> | null = null;
26
+
27
+ /** The single ordering owner for every gesture that crosses z/lane persistence. */
28
+ export function serializeZLaneGesture<Result>(run: () => Promise<Result>): Promise<Result> {
29
+ const previous = gestureTail;
30
+ // Preserve synchronous optimistic updates for the first gesture. A queued
31
+ // gesture starts only after the preceding gesture fully settled.
32
+ let gesture: Promise<Result>;
33
+ if (previous) {
34
+ gesture = previous.then(run, run);
35
+ } else {
36
+ try {
37
+ gesture = Promise.resolve(run());
38
+ } catch (error) {
39
+ gesture = Promise.reject(error);
40
+ }
41
+ }
42
+ const tail = gesture.then(
43
+ () => undefined,
44
+ () => undefined,
45
+ );
46
+ gestureTail = tail;
47
+ void tail.then(() => {
48
+ if (gestureTail === tail) gestureTail = null;
49
+ });
50
+ return gesture;
51
+ }
52
+
53
+ export function runZLaneGesture(input: {
54
+ /** Phase 1: persist the z patch (handleDomZIndexReorderCommit). */
55
+ commitZ: () => Promise<DomEditPatchBatchesResult | undefined | void>;
56
+ /** Phase 2: mirror into a timeline lane move; only runs on a durable phase 1. */
57
+ mirror: () => Promise<boolean>;
58
+ }): Promise<boolean> {
59
+ const run = async (): Promise<boolean> => {
60
+ const result = await input.commitZ();
61
+ if (result && !result.durable) return false;
62
+ return input.mirror();
63
+ };
64
+ return serializeZLaneGesture(run);
65
+ }
66
+
67
+ /** Run one lane→z gesture under the same owner as canvas z→lane gestures. */
68
+ export function runLaneZGesture(input: {
69
+ commitLane: () => Promise<boolean>;
70
+ commitZ: () => Promise<void>;
71
+ }): Promise<boolean> {
72
+ return serializeZLaneGesture(async () => {
73
+ if (!(await input.commitLane())) return false;
74
+ await input.commitZ();
75
+ return true;
76
+ });
77
+ }
@@ -11,6 +11,8 @@ export type CommitDomEditPatchBatches = (
11
11
  options: {
12
12
  label: string;
13
13
  coalesceKey: string;
14
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
15
+ coalesceMs?: number;
14
16
  /**
15
17
  * Request skipping the preview iframe reload after a successful persist.
16
18
  * Only honored when the persist is provably in sync with the live DOM:
@@ -20,7 +22,21 @@ export type CommitDomEditPatchBatches = (
20
22
  */
21
23
  skipReload?: boolean;
22
24
  },
23
- ) => Promise<void>;
25
+ ) => Promise<DomEditPatchBatchesResult>;
26
+
27
+ /**
28
+ * Durability report for a patch-batches commit. `durable === false` means the
29
+ * server could not locate at least one patch target on disk — the preview was
30
+ * reloaded to reconverge, and dependent follow-up writes (the z→lane timeline
31
+ * mirror) must be skipped. `allMatched` retains the underlying match detail.
32
+ * `changed === false` means no source file was written: every patch was a
33
+ * byte-identical no-op, or the atomic gesture was refused before any write.
34
+ */
35
+ export interface DomEditPatchBatchesResult {
36
+ durable: boolean;
37
+ allMatched: boolean;
38
+ changed: boolean;
39
+ }
24
40
 
25
41
  export type PersistDomEditOperations = (
26
42
  selection: DomEditSelection,
@@ -0,0 +1,25 @@
1
+ import { vi } from "vitest";
2
+ import type { useElementLifecycleOps } from "./useElementLifecycleOps";
3
+
4
+ type LifecycleOpsParams = Parameters<typeof useElementLifecycleOps>[0];
5
+
6
+ /**
7
+ * Baseline `useElementLifecycleOps` params for tests: inert stubs for every
8
+ * dependency, overridden per test (typically just `commitDomEditPatchBatches`).
9
+ * Shared by the z-reorder commit tests and the timeline-mirror harness.
10
+ */
11
+ export function makeLifecycleOpsParams(
12
+ overrides: Partial<LifecycleOpsParams> & Pick<LifecycleOpsParams, "commitDomEditPatchBatches">,
13
+ ): LifecycleOpsParams {
14
+ return {
15
+ activeCompPath: "index.html",
16
+ showToast: vi.fn(),
17
+ writeProjectFile: vi.fn(async () => {}),
18
+ domEditSaveTimestampRef: { current: 0 },
19
+ editHistory: { recordEdit: vi.fn(async () => {}) },
20
+ projectIdRef: { current: null },
21
+ reloadPreview: vi.fn(),
22
+ clearDomSelection: vi.fn(),
23
+ ...overrides,
24
+ };
25
+ }
@@ -0,0 +1,16 @@
1
+ // Shared helpers for test-side `fetch` stubs (timelineTimingSync.test.ts,
2
+ // useTimelineEditing.test.tsx): a JSON Response factory and a Request → URL
3
+ // normalizer. Test-only module — imported exclusively from *.test.* files.
4
+
5
+ export function jsonResponse(body: unknown): Response {
6
+ return new Response(JSON.stringify(body), {
7
+ status: 200,
8
+ headers: { "content-type": "application/json" },
9
+ });
10
+ }
11
+
12
+ export function requestUrl(input: Parameters<typeof fetch>[0]): string {
13
+ if (typeof input === "string") return input;
14
+ if (input instanceof URL) return input.toString();
15
+ return input.url;
16
+ }
@@ -5,6 +5,7 @@ import {
5
5
  buildTimelineMoveTimingPatch,
6
6
  deleteSelectedKeyframes,
7
7
  extendRootDurationIfNeeded,
8
+ patchIframeDomTiming,
8
9
  persistTimelineBatchEdit,
9
10
  type PersistTimelineBatchChange,
10
11
  } from "./timelineEditingHelpers";
@@ -42,7 +43,11 @@ describe("applyTimelineStackingReorder", () => {
42
43
  it("commits via the change's own locator even when the element is not in timelineElements", () => {
43
44
  // Sub-comp children live in the preview iframe but NOT in the top-level
44
45
  // timelineElements list — the intent must be self-contained.
45
- const iframe = makeIframeWith(`<div id="chip" style="z-index: 1"></div>`);
46
+ const iframe = makeIframeWith(`
47
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
48
+ <div id="chip" style="z-index: 1"></div>
49
+ </div>
50
+ `);
46
51
  const commit = vi.fn<(entries: unknown[]) => void>();
47
52
 
48
53
  applyTimelineStackingReorder({
@@ -96,6 +101,92 @@ describe("applyTimelineStackingReorder", () => {
96
101
 
97
102
  expect(commit).not.toHaveBeenCalled();
98
103
  });
104
+
105
+ it("resolves selectorIndex within the change's source file", () => {
106
+ const iframe = makeIframeWith(`
107
+ <div data-composition-id="root">
108
+ <div class="chip">root</div>
109
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
110
+ <div class="chip">scene zero</div>
111
+ <div class="chip" style="z-index: 1">scene one</div>
112
+ </div>
113
+ </div>
114
+ `);
115
+ const sceneOne = iframe.contentDocument?.querySelectorAll(".chip")[2];
116
+ const commit = vi.fn<(entries: unknown[]) => void>();
117
+
118
+ applyTimelineStackingReorder({
119
+ element: el({ id: "scene-one", tag: "div" }),
120
+ stackingReorder: {
121
+ contextKey: "scene",
122
+ placement: { type: "above", layerId: "layer:scene:x" },
123
+ zIndexChanges: [
124
+ {
125
+ key: "scenes/scene.html#.chip:1",
126
+ zIndex: 5,
127
+ selector: ".chip",
128
+ selectorIndex: 1,
129
+ sourceFile: "scenes/scene.html",
130
+ },
131
+ ],
132
+ },
133
+ timelineElements: [],
134
+ iframe,
135
+ activeCompPath: "index.html",
136
+ commit,
137
+ });
138
+
139
+ const entries = commit.mock.calls[0]![0] as Array<{ element: Element }>;
140
+ expect(entries[0]!.element).toBe(sceneOne);
141
+ });
142
+ });
143
+
144
+ describe("patchIframeDomTiming", () => {
145
+ it("resolves selectorIndex within the element's source file", () => {
146
+ const iframe = makeIframeWith(`
147
+ <div data-composition-id="root">
148
+ <div class="clip" data-start="1">root</div>
149
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
150
+ <div class="clip" data-start="2">scene zero</div>
151
+ <div class="clip" data-start="3">scene one</div>
152
+ </div>
153
+ </div>
154
+ `);
155
+ const clips = iframe.contentDocument?.querySelectorAll<HTMLElement>(".clip");
156
+ const target = el({
157
+ id: "scene-one",
158
+ tag: "div",
159
+ selector: ".clip",
160
+ selectorIndex: 1,
161
+ sourceFile: "scenes/scene.html",
162
+ });
163
+
164
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
165
+
166
+ expect([...clips!].map((clip) => clip.dataset.start)).toEqual(["1", "2", "9"]);
167
+ });
168
+
169
+ it("resolves duplicate domId within the element's source file", () => {
170
+ const iframe = makeIframeWith(`
171
+ <div data-composition-id="root">
172
+ <div id="card" data-start="1">root</div>
173
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
174
+ <div id="card" data-start="2">scene</div>
175
+ </div>
176
+ </div>
177
+ `);
178
+ const cards = iframe.contentDocument?.querySelectorAll<HTMLElement>("#card");
179
+ const target = el({
180
+ id: "scene-card",
181
+ tag: "div",
182
+ domId: "card",
183
+ sourceFile: "scenes/scene.html",
184
+ });
185
+
186
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
187
+
188
+ expect([...cards!].map((card) => card.dataset.start)).toEqual(["1", "9"]);
189
+ });
99
190
  });
100
191
 
101
192
  describe("extendRootDurationIfNeeded", () => {
@@ -13,6 +13,7 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
13
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
14
14
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
15
  import { readFileContent } from "./timelineTimingSync";
16
+ import { findElementForSelection } from "../components/editor/domEditingElement";
16
17
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
17
18
  export { readFileContent };
18
19
  function isHTMLElement(element: Element | null): element is HTMLElement {
@@ -53,12 +54,6 @@ export function applyTimelineStackingReorder(input: {
53
54
  input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
54
55
  );
55
56
  const doc = input.iframe?.contentDocument ?? null;
56
- const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
57
- if (!doc) return null;
58
- if (domId) return doc.getElementById(domId);
59
- if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
60
- return null;
61
- };
62
57
  const commitEntries: Array<{
63
58
  element: HTMLElement;
64
59
  zIndex: number;
@@ -73,7 +68,15 @@ export function applyTimelineStackingReorder(input: {
73
68
  const domId = change.domId ?? sibling?.domId;
74
69
  const selector = change.selector ?? sibling?.selector;
75
70
  const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
76
- const element = findLive(domId, selector, selectorIndex);
71
+ const sourceFile =
72
+ change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html";
73
+ const element = doc
74
+ ? findElementForSelection(
75
+ doc,
76
+ { id: domId, selector, selectorIndex, sourceFile },
77
+ input.activeCompPath,
78
+ )
79
+ : null;
77
80
  if (!isHTMLElement(element)) return Promise.resolve();
78
81
  if (getElementZIndex(element) === change.zIndex) continue;
79
82
  commitEntries.push({
@@ -82,12 +85,15 @@ export function applyTimelineStackingReorder(input: {
82
85
  id: domId ?? sibling?.id ?? change.key,
83
86
  selector,
84
87
  selectorIndex,
85
- sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
88
+ sourceFile,
86
89
  key: change.key,
87
90
  });
88
91
  }
89
92
  if (commitEntries.length === 0) return Promise.resolve();
90
- return input.commit?.(commitEntries, input.coalesceKey) ?? Promise.resolve();
93
+ // The durability report is for gesture-level callers (z→lane mirror); this
94
+ // lane-drag z-sync path has no dependent follow-up write — swallow it.
95
+ // Promise.resolve-wrapped: a commit implementation may return void.
96
+ return Promise.resolve(input.commit?.(commitEntries, input.coalesceKey)).then(() => undefined);
91
97
  }
92
98
  export function extendRootDurationIfNeeded(newEnd: number): boolean {
93
99
  const store = usePlayerStore.getState();
@@ -126,15 +132,22 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
126
132
  export function findTimelineElementInIframe(
127
133
  iframe: HTMLIFrameElement | null,
128
134
  element: TimelineElement,
135
+ activeCompositionPath: string | null = null,
129
136
  ): Element | null {
130
137
  try {
131
138
  const doc = iframe?.contentDocument;
132
139
  if (!doc) return null;
133
- return element.domId
134
- ? doc.getElementById(element.domId)
135
- : element.selector
136
- ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
137
- : null;
140
+ return findElementForSelection(
141
+ doc,
142
+ {
143
+ hfId: element.hfId,
144
+ id: element.domId,
145
+ selector: element.selector,
146
+ selectorIndex: element.selectorIndex,
147
+ sourceFile: element.sourceFile || activeCompositionPath || "index.html",
148
+ },
149
+ activeCompositionPath,
150
+ );
138
151
  } catch {
139
152
  return null;
140
153
  }
@@ -143,9 +156,10 @@ export function patchIframeDomTiming(
143
156
  iframe: HTMLIFrameElement | null,
144
157
  element: TimelineElement,
145
158
  attrs: Array<[string, string]>,
159
+ activeCompositionPath: string | null = null,
146
160
  ): void {
147
161
  try {
148
- const el = findTimelineElementInIframe(iframe, element);
162
+ const el = findTimelineElementInIframe(iframe, element, activeCompositionPath);
149
163
  if (!el) return;
150
164
  for (const [name, value] of attrs) el.setAttribute(name, value);
151
165
  } catch {
@@ -299,6 +313,8 @@ export interface PersistTimelineBatchEditInput {
299
313
  domEditSaveTimestampRef: React.MutableRefObject<number>;
300
314
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
301
315
  coalesceKey?: string;
316
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
317
+ coalesceMs?: number;
302
318
  }
303
319
 
304
320
  export async function persistTimelineBatchEdit(
@@ -347,6 +363,7 @@ export async function persistTimelineBatchEdit(
347
363
  label: input.label,
348
364
  kind: "timeline",
349
365
  coalesceKey: input.coalesceKey,
366
+ coalesceMs: input.coalesceMs,
350
367
  files,
351
368
  readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
352
369
  writeFile: input.writeProjectFile,
@@ -23,6 +23,7 @@ export function persistTimelineMoveEditsAtomically(
23
23
  coalesceKey: string | undefined,
24
24
  operation: TimelineMoveOperation,
25
25
  deps: AtomicMoveDeps,
26
+ coalesceMs?: number,
26
27
  ): Promise<void> {
27
28
  return deps.handleTimelineGroupMove(
28
29
  edits.map(({ element, updates }) => ({
@@ -34,6 +35,6 @@ export function persistTimelineMoveEditsAtomically(
34
35
  // ("timing") omit it so they stay eligible for the SDK fast path.
35
36
  track: operation === "timing" ? undefined : updates.track,
36
37
  })),
37
- { coalesceKey },
38
+ { coalesceKey, coalesceMs },
38
39
  );
39
40
  }