@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.
- package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.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 +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- 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 +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- 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/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- 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/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- 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 +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- 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/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- 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 +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -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/dist/assets/index-_pqzyxB1.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
|
+
}
|
|
@@ -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<
|
|
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(
|
|
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
|
|
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
|
|
88
|
+
sourceFile,
|
|
86
89
|
key: change.key,
|
|
87
90
|
});
|
|
88
91
|
}
|
|
89
92
|
if (commitEntries.length === 0) return Promise.resolve();
|
|
90
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
:
|
|
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
|
}
|