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