@hyperframes/studio 0.7.56 → 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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.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 +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  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 +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  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/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /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
+ }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
7
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8
+ import { AssetCard } from "./AssetCard";
9
+ import { AudioRow } from "./AudioRow";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ let root: Root | null = null;
14
+
15
+ afterEach(() => {
16
+ if (root) {
17
+ act(() => root?.unmount());
18
+ root = null;
19
+ }
20
+ document.body.innerHTML = "";
21
+ usePlayerStore.getState().reset();
22
+ useAssetPreviewStore.getState().clearPreviewAsset();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ function mount(node: React.ReactElement): HTMLElement {
27
+ const host = document.createElement("div");
28
+ document.body.append(host);
29
+ root = createRoot(host);
30
+ act(() => {
31
+ root?.render(node);
32
+ });
33
+ return host;
34
+ }
35
+
36
+ function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
37
+ return { tag: "div", start: 0, duration: 5, track: 0, ...input };
38
+ }
39
+
40
+ /** Simulate a drag-free click: pointerdown + pointerup at the same point. */
41
+ function clickCard(host: HTMLElement): void {
42
+ const card = host.querySelector('[draggable="true"]');
43
+ if (!card) throw new Error("Expected a draggable card root");
44
+ const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
45
+ act(() => {
46
+ card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
47
+ card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
48
+ });
49
+ }
50
+
51
+ describe("AssetCard click behavior", () => {
52
+ const cardProps = {
53
+ projectId: "p1",
54
+ onCopy: vi.fn(),
55
+ isCopied: false,
56
+ };
57
+
58
+ it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
59
+ usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
60
+ // Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
61
+ // or it stays stuck over the canvas while the timeline reveals the clip.
62
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
63
+
64
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
65
+ clickCard(host);
66
+
67
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
68
+ expect(usePlayerStore.getState().selectedElementId).toBe("img1");
69
+ });
70
+
71
+ it("opens the preview overlay for a not-yet-added asset", () => {
72
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
73
+ clickCard(host);
74
+
75
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
76
+ expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
77
+ });
78
+ });
79
+
80
+ describe("AudioRow click behavior", () => {
81
+ const rowProps = {
82
+ projectId: "p1",
83
+ onCopy: vi.fn(),
84
+ isCopied: false,
85
+ };
86
+
87
+ it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
88
+ usePlayerStore
89
+ .getState()
90
+ .setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
91
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
92
+
93
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
94
+ clickCard(host);
95
+
96
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
97
+ expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
98
+ });
99
+
100
+ it("opens the preview overlay for a not-yet-added audio asset", () => {
101
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
102
+ clickCard(host);
103
+
104
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
105
+ });
106
+ });
@@ -133,8 +133,10 @@ export function AssetCard({
133
133
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
134
134
 
135
135
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
136
137
  const elements = usePlayerStore((s) => s.elements);
137
138
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
138
140
 
139
141
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
140
142
  pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -150,14 +152,29 @@ export function AssetCard({
150
152
  if (used) {
151
153
  const clip = findClipForAsset(elements, asset);
152
154
  if (clip) {
153
- setSelectedElementId(clip.key ?? clip.id);
155
+ // Dismiss any open preview overlay (from another asset) — the reveal
156
+ // must not leave a stale preview card floating over the canvas.
157
+ clearPreviewAsset();
158
+ const clipKey = clip.key ?? clip.id;
159
+ setSelectedElementId(clipKey);
160
+ // Scroll the timeline so the selected clip is actually visible.
161
+ requestClipReveal(clipKey);
154
162
  return;
155
163
  }
156
164
  }
157
165
  // Not added (or no matching clip found) → preview overlay
158
166
  setPreviewAsset(asset, projectId);
159
167
  },
160
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
168
+ [
169
+ used,
170
+ elements,
171
+ asset,
172
+ projectId,
173
+ setSelectedElementId,
174
+ requestClipReveal,
175
+ setPreviewAsset,
176
+ clearPreviewAsset,
177
+ ],
161
178
  );
162
179
 
163
180
  return (
@@ -43,8 +43,10 @@ export function AudioRow({
43
43
  // CapCut-style click behavior: drag-threshold gate.
44
44
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
45
45
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
46
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
46
47
  const elements = usePlayerStore((s) => s.elements);
47
48
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
49
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
48
50
 
49
51
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
50
52
  pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -59,14 +61,29 @@ export function AudioRow({
59
61
  if (used) {
60
62
  const clip = findClipForAsset(elements, asset);
61
63
  if (clip) {
62
- setSelectedElementId(clip.key ?? clip.id);
64
+ // Dismiss any open preview overlay (from another asset) — the reveal
65
+ // must not leave a stale preview card floating over the canvas.
66
+ clearPreviewAsset();
67
+ const clipKey = clip.key ?? clip.id;
68
+ setSelectedElementId(clipKey);
69
+ // Scroll the timeline so the selected clip is actually visible.
70
+ requestClipReveal(clipKey);
63
71
  return;
64
72
  }
65
73
  }
66
74
  // Not added → preview overlay (audio player)
67
75
  setPreviewAsset(asset, projectId);
68
76
  },
69
- [used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
77
+ [
78
+ used,
79
+ elements,
80
+ asset,
81
+ projectId,
82
+ setSelectedElementId,
83
+ requestClipReveal,
84
+ setPreviewAsset,
85
+ clearPreviewAsset,
86
+ ],
70
87
  );
71
88
 
72
89
  useEffect(() => {
@@ -8,8 +8,35 @@ export interface DomEditPatchBatch {
8
8
 
9
9
  export type CommitDomEditPatchBatches = (
10
10
  batches: DomEditPatchBatch[],
11
- options: { label: string; coalesceKey: string },
12
- ) => Promise<void>;
11
+ options: {
12
+ label: string;
13
+ coalesceKey: string;
14
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
15
+ coalesceMs?: number;
16
+ /**
17
+ * Request skipping the preview iframe reload after a successful persist.
18
+ * Only honored when the persist is provably in sync with the live DOM:
19
+ * every patch operation is inline-style-only AND the server matched every
20
+ * patch target. Any unmatched target (or a non-style op) falls back to the
21
+ * reload so the preview reconverges with disk. Default: always reload.
22
+ */
23
+ skipReload?: boolean;
24
+ },
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
+ }
13
40
 
14
41
  export type PersistDomEditOperations = (
15
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
+ }