@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.
- package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
[
|
|
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: {
|
|
12
|
-
|
|
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
|
+
}
|