@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.
Files changed (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. 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
+ });