@hyperframes/studio 0.7.54 → 0.7.55
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/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
- package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/hooks/useRazorSplit.history.test.tsx +173 -0
- package/src/player/components/timelineCollision.test.ts +437 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure z-order helpers for the canvas right-click context menu.
|
|
3
|
+
*
|
|
4
|
+
* Layering strategy: z-index + CSS stacking context (position ≠ static).
|
|
5
|
+
* All sibling z-index values are read from the live iframe DOM via
|
|
6
|
+
* element.style.zIndex (inline style, set by the editor) falling back to
|
|
7
|
+
* the computed value. Treat missing / "auto" as 0 for comparison purposes.
|
|
8
|
+
*
|
|
9
|
+
* "Overlapping siblings" = siblings whose bounding rects intersect the
|
|
10
|
+
* target's bounding rect. Forward/backward operate within that set;
|
|
11
|
+
* front/back operate across all siblings.
|
|
12
|
+
*
|
|
13
|
+
* ── Tie-awareness ────────────────────────────────────────────────────────────
|
|
14
|
+
* CSS paint order for elements that share a z-index is DOM document order:
|
|
15
|
+
* the element that comes LATER in the DOM paints ON TOP. The old resolver
|
|
16
|
+
* compared z-index alone, so a target tied with the element visually below it
|
|
17
|
+
* (equal z, target later in DOM) had an empty "below" set and silently
|
|
18
|
+
* no-op'd. This module computes true render order — sort by
|
|
19
|
+
* (zIndex asc, DOM position asc), bottom→top — moves the target one step (or
|
|
20
|
+
* to an end) in that order, then realizes the new order back into z values.
|
|
21
|
+
*
|
|
22
|
+
* The result is a MULTI-element patch: a single-element patch when a
|
|
23
|
+
* strictly-between z value can express the new order given DOM-order
|
|
24
|
+
* tie-breaking, otherwise a minimal renumber of the affected set (emitting
|
|
25
|
+
* patches only for elements whose z actually changes). z is never negative
|
|
26
|
+
* (project convention clamps z ≥ 0).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
|
|
30
|
+
|
|
31
|
+
/** A resolved change: set `element`'s z-index to `zIndex`. */
|
|
32
|
+
export interface ZOrderPatch {
|
|
33
|
+
element: HTMLElement;
|
|
34
|
+
zIndex: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RenderEntry {
|
|
38
|
+
element: HTMLElement;
|
|
39
|
+
zIndex: number;
|
|
40
|
+
/** Position within the shared parent's children (DOM document order). */
|
|
41
|
+
domIndex: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse a z-index string to a number; treats "auto" / empty as 0. */
|
|
45
|
+
export function parseZIndex(value: string | null | undefined): number {
|
|
46
|
+
if (!value || value === "auto") return 0;
|
|
47
|
+
const n = parseInt(value, 10);
|
|
48
|
+
return Number.isFinite(n) ? n : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read the effective z-index for an element (inline style preferred). */
|
|
52
|
+
export function readEffectiveZIndex(el: HTMLElement): number {
|
|
53
|
+
const inline = el.style.zIndex;
|
|
54
|
+
if (inline && inline !== "auto") return parseZIndex(inline);
|
|
55
|
+
try {
|
|
56
|
+
const win = el.ownerDocument?.defaultView;
|
|
57
|
+
if (win) return parseZIndex(win.getComputedStyle(el).zIndex);
|
|
58
|
+
} catch {
|
|
59
|
+
/* cross-origin / detached */
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Realm-safe HTMLElement check. The target lives in the preview IFRAME's
|
|
66
|
+
* document, but this module runs in the top window, so `child instanceof
|
|
67
|
+
* HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
|
|
68
|
+
* which silently emptied the sibling list and left every z-order action
|
|
69
|
+
* permanently disabled. Compare against the element's own realm instead, with
|
|
70
|
+
* a nodeType fallback for detached / cross-realm edge cases.
|
|
71
|
+
*/
|
|
72
|
+
function isElementNode(node: Node): node is HTMLElement {
|
|
73
|
+
const view = node.ownerDocument?.defaultView;
|
|
74
|
+
if (view && node instanceof view.HTMLElement) return true;
|
|
75
|
+
return node.nodeType === 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tags that never paint pixels and so must be excluded from z-order siblings.
|
|
80
|
+
* `<audio>` is the real offender here: a prior renumber wrote a meaningless
|
|
81
|
+
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
|
82
|
+
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
|
83
|
+
* non-painting and could otherwise pad the family / eat a z slot.
|
|
84
|
+
*/
|
|
85
|
+
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
|
|
86
|
+
|
|
87
|
+
/** A painting element: an element node whose tag actually renders pixels. */
|
|
88
|
+
function isPaintingElement(node: Node): node is HTMLElement {
|
|
89
|
+
return isElementNode(node) && !NON_PAINTING_TAGS.has(node.tagName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Collect the target plus every PAINTING HTMLElement sibling (same parent),
|
|
94
|
+
* tagged with DOM document position. Non-painting siblings (audio/script/style/
|
|
95
|
+
* link/meta) are skipped so they neither pad the family nor consume a z slot in
|
|
96
|
+
* the renumber path. Returns the target's own index within the result.
|
|
97
|
+
*/
|
|
98
|
+
function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex: number } {
|
|
99
|
+
const parent = target.parentElement;
|
|
100
|
+
if (!parent) return { entries: [], targetIndex: -1 };
|
|
101
|
+
const entries: RenderEntry[] = [];
|
|
102
|
+
let targetIndex = -1;
|
|
103
|
+
let domIndex = 0;
|
|
104
|
+
for (const child of Array.from(parent.children)) {
|
|
105
|
+
// The target is always retained even if its own tag is non-painting.
|
|
106
|
+
if (child !== target && !isPaintingElement(child)) continue;
|
|
107
|
+
if (!isElementNode(child)) continue;
|
|
108
|
+
if (child === target) targetIndex = entries.length;
|
|
109
|
+
entries.push({ element: child, zIndex: readEffectiveZIndex(child), domIndex });
|
|
110
|
+
domIndex += 1;
|
|
111
|
+
}
|
|
112
|
+
return { entries, targetIndex };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True if two DOM bounding rects intersect (even if touching). */
|
|
116
|
+
function rectsIntersect(
|
|
117
|
+
a: { left: number; top: number; right: number; bottom: number },
|
|
118
|
+
b: { left: number; top: number; right: number; bottom: number },
|
|
119
|
+
): boolean {
|
|
120
|
+
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Restrict a family to the target plus siblings whose bounding rect overlaps
|
|
125
|
+
* the target's rect. The target is always retained. If the target's rect is
|
|
126
|
+
* unavailable or empty (headless / happy-dom returns 0×0), all entries are
|
|
127
|
+
* kept — matching the prior behavior.
|
|
128
|
+
*/
|
|
129
|
+
function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
|
|
130
|
+
let targetRect: DOMRect;
|
|
131
|
+
try {
|
|
132
|
+
targetRect = target.getBoundingClientRect();
|
|
133
|
+
} catch {
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
if (targetRect.width === 0 && targetRect.height === 0) return entries;
|
|
137
|
+
const tr = {
|
|
138
|
+
left: targetRect.left,
|
|
139
|
+
top: targetRect.top,
|
|
140
|
+
right: targetRect.right,
|
|
141
|
+
bottom: targetRect.bottom,
|
|
142
|
+
};
|
|
143
|
+
return entries.filter((entry) => {
|
|
144
|
+
if (entry.element === target) return true;
|
|
145
|
+
try {
|
|
146
|
+
const r = entry.element.getBoundingClientRect();
|
|
147
|
+
return rectsIntersect(tr, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sort a family into render order (bottom→top): z asc, then DOM position asc. */
|
|
155
|
+
function toRenderOrder(entries: RenderEntry[]): RenderEntry[] {
|
|
156
|
+
return [...entries].sort((a, b) => a.zIndex - b.zIndex || a.domIndex - b.domIndex);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A z that lands the target strictly between `below` and `above` in render order,
|
|
161
|
+
* or null when no such value exists (a tie-prone gap, or no room below the floor)
|
|
162
|
+
* and the caller must renumber. Equal-z ties break by DOM order, so a plain
|
|
163
|
+
* equality can flip order unpredictably; require a strict gap and clamp at 0.
|
|
164
|
+
*/
|
|
165
|
+
function computeBetweenZ(
|
|
166
|
+
below: RenderEntry | undefined,
|
|
167
|
+
above: RenderEntry | undefined,
|
|
168
|
+
): number | null {
|
|
169
|
+
if (below && above) {
|
|
170
|
+
return above.zIndex - below.zIndex >= 2 ? below.zIndex + 1 : null;
|
|
171
|
+
}
|
|
172
|
+
if (below) return below.zIndex + 1; // move to top
|
|
173
|
+
if (above) {
|
|
174
|
+
const candidate = Math.max(0, above.zIndex - 1); // move to bottom
|
|
175
|
+
return candidate >= above.zIndex ? null : candidate; // no room below → renumber
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Realize a desired render order (bottom→top) into z-index patches for the
|
|
182
|
+
* given family, emitting patches ONLY for elements whose z actually changes.
|
|
183
|
+
*
|
|
184
|
+
* Fast path: if the SCOPED z values are all distinct, the render order is fully
|
|
185
|
+
* determined by z alone — a single-element move can be expressed by placing the
|
|
186
|
+
* target's z strictly between its new neighbours (or at an end), so at most one
|
|
187
|
+
* element changes (and it never disturbs an untouched pair, since only the target
|
|
188
|
+
* moves). When ties exist a between value can be impossible, so renumber — but the
|
|
189
|
+
* scoped set is only a SUBSET of the family (the target's overlapping siblings),
|
|
190
|
+
* so a naive 0..n-1 renumber can drop a scoped sibling below an untouched
|
|
191
|
+
* non-scoped one, reordering an untouched pair (#2202). `renumberScoped` keeps the
|
|
192
|
+
* scoped block inside its original z-band, bounded by the non-scoped siblings.
|
|
193
|
+
*/
|
|
194
|
+
function realizeOrder(
|
|
195
|
+
currentOrder: RenderEntry[],
|
|
196
|
+
desiredOrder: RenderEntry[],
|
|
197
|
+
target: HTMLElement,
|
|
198
|
+
family: RenderEntry[],
|
|
199
|
+
): ZOrderPatch[] | null {
|
|
200
|
+
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
|
201
|
+
if (targetPos === -1) return null;
|
|
202
|
+
|
|
203
|
+
const targetZ = readEffectiveZIndex(target);
|
|
204
|
+
|
|
205
|
+
// ── Fast path: distinct z values → a single between-value move suffices.
|
|
206
|
+
const zValues = currentOrder.map((e) => e.zIndex);
|
|
207
|
+
const hasDupes = zValues.some((v, i) => zValues.indexOf(v) !== i);
|
|
208
|
+
if (!hasDupes) {
|
|
209
|
+
const candidate = computeBetweenZ(desiredOrder[targetPos - 1], desiredOrder[targetPos + 1]);
|
|
210
|
+
if (candidate !== null) {
|
|
211
|
+
if (candidate === targetZ) return null;
|
|
212
|
+
return [{ element: target, zIndex: candidate }];
|
|
213
|
+
}
|
|
214
|
+
// else fall through to renumber
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return renumberScoped(currentOrder, desiredOrder, target, family);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Renumber the SCOPED set (the reordered subset) to distinct z, keeping the whole
|
|
222
|
+
* block within the band its members already occupied so no untouched scoped /
|
|
223
|
+
* non-scoped pair is reordered (#2202). The block is placed near its original base
|
|
224
|
+
* `lo`, but clamped to sit strictly above the highest non-scoped sibling below the
|
|
225
|
+
* band and strictly below the lowest non-scoped sibling above it. Only scoped
|
|
226
|
+
* members are patched; non-scoped siblings keep their authored z.
|
|
227
|
+
*
|
|
228
|
+
* If a non-scoped sibling sits INSIDE or tied to the band (no clean bracket), or
|
|
229
|
+
* the bracket is too narrow to hold `n` distinct integers, fall back to a
|
|
230
|
+
* whole-family renumber — less minimal but still preserves every relative order.
|
|
231
|
+
*/
|
|
232
|
+
function renumberScoped(
|
|
233
|
+
currentOrder: RenderEntry[],
|
|
234
|
+
desiredOrder: RenderEntry[],
|
|
235
|
+
target: HTMLElement,
|
|
236
|
+
family: RenderEntry[],
|
|
237
|
+
): ZOrderPatch[] | null {
|
|
238
|
+
const scoped = new Set(desiredOrder.map((e) => e.element));
|
|
239
|
+
const nonScoped = family.filter((e) => !scoped.has(e.element));
|
|
240
|
+
const n = desiredOrder.length;
|
|
241
|
+
const zs = currentOrder.map((e) => e.zIndex);
|
|
242
|
+
const lo = Math.min(...zs);
|
|
243
|
+
const hi = Math.max(...zs);
|
|
244
|
+
|
|
245
|
+
const bracketed = !nonScoped.some((e) => e.zIndex >= lo && e.zIndex <= hi);
|
|
246
|
+
if (bracketed) {
|
|
247
|
+
const below = nonScoped.filter((e) => e.zIndex < lo).map((e) => e.zIndex);
|
|
248
|
+
const above = nonScoped.filter((e) => e.zIndex > hi).map((e) => e.zIndex);
|
|
249
|
+
const minStart = below.length > 0 ? Math.max(...below) + 1 : 0; // z ≥ 0 convention
|
|
250
|
+
const hasUpper = above.length > 0;
|
|
251
|
+
const maxStart = hasUpper ? Math.min(...above) - n : Number.POSITIVE_INFINITY;
|
|
252
|
+
if (minStart <= maxStart) {
|
|
253
|
+
let start = Math.max(lo, minStart);
|
|
254
|
+
if (hasUpper) start = Math.min(start, maxStart);
|
|
255
|
+
const patches: ZOrderPatch[] = [];
|
|
256
|
+
desiredOrder.forEach((entry, i) => {
|
|
257
|
+
if (entry.zIndex !== start + i) patches.push({ element: entry.element, zIndex: start + i });
|
|
258
|
+
});
|
|
259
|
+
return patches.length === 0 ? null : patches;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Fallback: renumber the whole family so relative order is still preserved.
|
|
264
|
+
const desiredGlobal = buildGlobalOrder(family, desiredOrder, target);
|
|
265
|
+
const patches: ZOrderPatch[] = [];
|
|
266
|
+
desiredGlobal.forEach((entry, i) => {
|
|
267
|
+
if (entry.zIndex !== i) patches.push({ element: entry.element, zIndex: i });
|
|
268
|
+
});
|
|
269
|
+
return patches.length === 0 ? null : patches;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A whole-family render order (bottom→top) with the non-scoped siblings kept in
|
|
274
|
+
* their current relative order and the target reinserted beside its new SCOPED
|
|
275
|
+
* neighbour (just above the scoped element below it, else just below the scoped
|
|
276
|
+
* element above it). Used only by the renumber fallback.
|
|
277
|
+
*/
|
|
278
|
+
function buildGlobalOrder(
|
|
279
|
+
family: RenderEntry[],
|
|
280
|
+
desiredOrder: RenderEntry[],
|
|
281
|
+
target: HTMLElement,
|
|
282
|
+
): RenderEntry[] {
|
|
283
|
+
const full = toRenderOrder(family);
|
|
284
|
+
const targetEntry = full.find((e) => e.element === target);
|
|
285
|
+
const rest = full.filter((e) => e.element !== target);
|
|
286
|
+
if (!targetEntry) return rest;
|
|
287
|
+
const targetPos = desiredOrder.findIndex((e) => e.element === target);
|
|
288
|
+
const prev = desiredOrder[targetPos - 1];
|
|
289
|
+
const next = desiredOrder[targetPos + 1];
|
|
290
|
+
const prevIdx = prev ? rest.findIndex((e) => e.element === prev.element) : -1;
|
|
291
|
+
const nextIdx = next ? rest.findIndex((e) => e.element === next.element) : -1;
|
|
292
|
+
if (prevIdx >= 0) rest.splice(prevIdx + 1, 0, targetEntry);
|
|
293
|
+
else if (nextIdx >= 0) rest.splice(nextIdx, 0, targetEntry);
|
|
294
|
+
else rest.unshift(targetEntry);
|
|
295
|
+
return rest;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve the z-order patches for an action.
|
|
300
|
+
*
|
|
301
|
+
* Returns null when the action is a no-op (target already at the relevant
|
|
302
|
+
* end of its set), otherwise the minimal list of {element, zIndex} changes.
|
|
303
|
+
*/
|
|
304
|
+
export function resolveZOrderChange(
|
|
305
|
+
target: HTMLElement,
|
|
306
|
+
action: ZOrderAction,
|
|
307
|
+
): ZOrderPatch[] | null {
|
|
308
|
+
const { entries } = getFamily(target);
|
|
309
|
+
// Family always includes the target; fewer than 2 means no siblings at all.
|
|
310
|
+
if (entries.length < 2) return null;
|
|
311
|
+
|
|
312
|
+
const scoped =
|
|
313
|
+
action === "bring-to-front" || action === "send-to-back"
|
|
314
|
+
? entries
|
|
315
|
+
: getOverlappingFamily(target, entries);
|
|
316
|
+
if (scoped.length < 2) return null;
|
|
317
|
+
|
|
318
|
+
const order = toRenderOrder(scoped);
|
|
319
|
+
const pos = order.findIndex((e) => e.element === target);
|
|
320
|
+
if (pos === -1) return null;
|
|
321
|
+
|
|
322
|
+
const desired = [...order];
|
|
323
|
+
const [moved] = desired.splice(pos, 1);
|
|
324
|
+
switch (action) {
|
|
325
|
+
case "bring-forward":
|
|
326
|
+
if (pos >= order.length - 1) return null; // already top of set
|
|
327
|
+
desired.splice(pos + 1, 0, moved);
|
|
328
|
+
break;
|
|
329
|
+
case "send-backward":
|
|
330
|
+
if (pos <= 0) return null; // already bottom of set
|
|
331
|
+
desired.splice(pos - 1, 0, moved);
|
|
332
|
+
break;
|
|
333
|
+
case "bring-to-front":
|
|
334
|
+
if (pos >= order.length - 1) return null;
|
|
335
|
+
desired.push(moved);
|
|
336
|
+
break;
|
|
337
|
+
case "send-to-back":
|
|
338
|
+
if (pos <= 0) return null;
|
|
339
|
+
desired.unshift(moved);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return realizeOrder(order, desired, target, entries);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Whether a z-order action is available for the target.
|
|
348
|
+
* "disabled" = the element is already at that limit.
|
|
349
|
+
*/
|
|
350
|
+
export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
|
|
351
|
+
return resolveZOrderChange(target, action) !== null;
|
|
352
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../player";
|
|
7
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
8
|
+
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
|
|
9
|
+
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
|
10
|
+
import { createEmptyEditHistory } from "../utils/editHistory";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
|
|
15
|
+
const SPLIT =
|
|
16
|
+
`<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
|
|
17
|
+
`<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;
|
|
18
|
+
|
|
19
|
+
const element: TimelineElement = {
|
|
20
|
+
id: "clip1",
|
|
21
|
+
tag: "div",
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 4,
|
|
24
|
+
track: 0,
|
|
25
|
+
domId: "clip1",
|
|
26
|
+
sourceFile: "index.html",
|
|
27
|
+
timingSource: "authored",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
31
|
+
|
|
32
|
+
interface Harness {
|
|
33
|
+
disk: Record<string, string>;
|
|
34
|
+
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
|
35
|
+
splitRef: { current: Split | undefined };
|
|
36
|
+
root: ReturnType<typeof createRoot>;
|
|
37
|
+
expected: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SPLIT_GSAP = SPLIT.replace(
|
|
41
|
+
"</div>",
|
|
42
|
+
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
|
|
43
|
+
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
|
|
47
|
+
const disk: Record<string, string> = { "index.html": ORIGINAL };
|
|
48
|
+
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
|
|
49
|
+
|
|
50
|
+
const storage = createMemoryEditHistoryStorage();
|
|
51
|
+
const store = createPersistentEditHistoryStore({
|
|
52
|
+
projectId: "p1",
|
|
53
|
+
storage,
|
|
54
|
+
initialState: createEmptyEditHistory(),
|
|
55
|
+
now: (() => {
|
|
56
|
+
let t = 1000;
|
|
57
|
+
return () => (t += 10);
|
|
58
|
+
})(),
|
|
59
|
+
onChange: () => {},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Faithful stand-in for the studio-server file-mutation endpoints: the server
|
|
63
|
+
// writes the split to disk itself, then returns the patched content.
|
|
64
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
65
|
+
const u = String(url);
|
|
66
|
+
if (u.includes("/gsap-mutations/")) {
|
|
67
|
+
if (opts.gsap) {
|
|
68
|
+
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
|
69
|
+
// disk, returns the final content.
|
|
70
|
+
disk["index.html"] = SPLIT_GSAP;
|
|
71
|
+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
|
|
72
|
+
status: 200,
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
77
|
+
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
|
78
|
+
status: 400,
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (u.includes("/file-mutations/split-element/")) {
|
|
83
|
+
disk["index.html"] = SPLIT;
|
|
84
|
+
return new Response(
|
|
85
|
+
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
|
|
86
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (u.includes("/files/")) {
|
|
90
|
+
return new Response(JSON.stringify({ content: disk["index.html"] }), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
void init;
|
|
96
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
97
|
+
});
|
|
98
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
99
|
+
|
|
100
|
+
const splitRef: { current: Split | undefined } = { current: undefined };
|
|
101
|
+
|
|
102
|
+
function Component() {
|
|
103
|
+
const { handleRazorSplit } = useRazorSplit({
|
|
104
|
+
projectId: "p1",
|
|
105
|
+
activeCompPath: "index.html",
|
|
106
|
+
showToast: () => {},
|
|
107
|
+
writeProjectFile: async (path, content) => {
|
|
108
|
+
disk[path] = content;
|
|
109
|
+
},
|
|
110
|
+
recordEdit: store.recordEdit,
|
|
111
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
112
|
+
reloadPreview: () => {},
|
|
113
|
+
});
|
|
114
|
+
splitRef.current = handleRazorSplit;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const host = document.createElement("div");
|
|
119
|
+
document.body.append(host);
|
|
120
|
+
const root = createRoot(host);
|
|
121
|
+
act(() => {
|
|
122
|
+
root.render(<Component />);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return { disk, store, splitRef, root, expected: finalContent };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function undoViaDisk(harness: Harness) {
|
|
129
|
+
return harness.store.undo({
|
|
130
|
+
readFile: async (path) => harness.disk[path],
|
|
131
|
+
writeFile: async (path, content) => {
|
|
132
|
+
harness.disk[path] = content;
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
document.body.innerHTML = "";
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("useRazorSplit — split is undoable via edit history", () => {
|
|
143
|
+
for (const gsap of [false, true]) {
|
|
144
|
+
describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
|
|
145
|
+
let harness: Harness;
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
harness = mountRazorSplit({ gsap });
|
|
148
|
+
});
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
act(() => harness.root.unmount());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("records a single 'Split timeline clip' history entry that undo restores", async () => {
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await harness.splitRef.current!(element, 2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// The split reached disk.
|
|
159
|
+
expect(harness.disk["index.html"]).toBe(harness.expected);
|
|
160
|
+
|
|
161
|
+
// The split must be the top of the undo stack — not a prior/other entry.
|
|
162
|
+
expect(harness.store.snapshot().canUndo).toBe(true);
|
|
163
|
+
expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
|
|
164
|
+
|
|
165
|
+
// Undo restores the exact pre-split file.
|
|
166
|
+
const result = await undoViaDisk(harness);
|
|
167
|
+
expect(result.ok).toBe(true);
|
|
168
|
+
expect(result.label).toBe("Split timeline clip");
|
|
169
|
+
expect(harness.disk["index.html"]).toBe(ORIGINAL);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
});
|