@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,435 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ isZOrderActionEnabled,
5
+ parseZIndex,
6
+ resolveZOrderChange,
7
+ type ZOrderAction,
8
+ type ZOrderPatch,
9
+ } from "./canvasContextMenuZOrder";
10
+
11
+ // ── helpers ───────────────────────────────────────────────────────────────────
12
+ //
13
+ // In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0
14
+ // and getOverlappingFamily returns the whole family — i.e. every sibling is
15
+ // treated as overlapping. That makes forward/backward and front/back exercise
16
+ // the same scoped set here, which is exactly what we want for order logic.
17
+
18
+ function makeEl(id: string, zIndex?: string): HTMLElement {
19
+ const el = document.createElement("div");
20
+ el.id = id;
21
+ if (zIndex !== undefined) el.style.zIndex = zIndex;
22
+ return el;
23
+ }
24
+
25
+ /**
26
+ * Build a parent and append `[target, ...siblings]` in DOM order. Each spec is
27
+ * [id, z]. The target's z is `targetZ`; it is appended FIRST unless
28
+ * `targetLast` is set, in which case it is appended LAST (later in DOM).
29
+ */
30
+ function makeFamily(
31
+ targetZ: string,
32
+ siblingSpecs: Array<[string, string]>,
33
+ opts: { targetLast?: boolean } = {},
34
+ ): { target: HTMLElement; parent: HTMLElement; byId: Record<string, HTMLElement> } {
35
+ const parent = document.createElement("div");
36
+ const target = makeEl("target", targetZ);
37
+ const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z));
38
+ const byId: Record<string, HTMLElement> = { target };
39
+ for (const s of siblings) byId[s.id] = s;
40
+ if (opts.targetLast) {
41
+ for (const s of siblings) parent.appendChild(s);
42
+ parent.appendChild(target);
43
+ } else {
44
+ parent.appendChild(target);
45
+ for (const s of siblings) parent.appendChild(s);
46
+ }
47
+ return { target, parent, byId };
48
+ }
49
+
50
+ /** Resolve a z-order change and assert it produced patches (fails otherwise). */
51
+ function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
52
+ const patches = resolveZOrderChange(target, action);
53
+ expect(patches).not.toBeNull();
54
+ if (!patches) throw new Error("expected z-order patches");
55
+ return patches;
56
+ }
57
+
58
+ /** Look up a patch for a given element id in a patch list. */
59
+ function patchFor(patches: ZOrderPatch[], byId: Record<string, HTMLElement>, id: string) {
60
+ return patches.find((p) => p.element === byId[id]);
61
+ }
62
+
63
+ /** Apply patches, then return the render-ordered ids (bottom→top). */
64
+ function renderOrderIds(
65
+ parent: HTMLElement,
66
+ byId: Record<string, HTMLElement>,
67
+ patches: ZOrderPatch[],
68
+ ): string[] {
69
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
70
+ const children = Array.from(parent.children) as HTMLElement[];
71
+ const withPos = children.map((el, domIndex) => ({
72
+ id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id,
73
+ z: parseZIndex(el.style.zIndex || "0"),
74
+ domIndex,
75
+ }));
76
+ withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex);
77
+ return withPos.map((e) => e.id);
78
+ }
79
+
80
+ // ── parseZIndex ───────────────────────────────────────────────────────────────
81
+
82
+ describe("parseZIndex", () => {
83
+ it("parses integers", () => {
84
+ expect(parseZIndex("5")).toBe(5);
85
+ expect(parseZIndex("0")).toBe(0);
86
+ expect(parseZIndex("-3")).toBe(-3);
87
+ });
88
+
89
+ it("treats 'auto' / null / undefined / empty as 0", () => {
90
+ expect(parseZIndex("auto")).toBe(0);
91
+ expect(parseZIndex(null)).toBe(0);
92
+ expect(parseZIndex(undefined)).toBe(0);
93
+ expect(parseZIndex("")).toBe(0);
94
+ });
95
+ });
96
+
97
+ // ── distinct-z fast path (single-element patch) ────────────────────────────────
98
+
99
+ describe("resolveZOrderChange – distinct z values (fast path)", () => {
100
+ it("bring-to-front moves target above all with a single patch", () => {
101
+ const { target, byId } = makeFamily("2", [
102
+ ["a", "1"],
103
+ ["b", "5"],
104
+ ["c", "3"],
105
+ ]);
106
+ const patches = resolveZOrderPatches(target, "bring-to-front");
107
+ expect(patches).toHaveLength(1);
108
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(6);
109
+ });
110
+
111
+ it("bring-to-front returns null when already on top", () => {
112
+ const { target } = makeFamily("6", [
113
+ ["a", "1"],
114
+ ["b", "5"],
115
+ ["c", "3"],
116
+ ]);
117
+ expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
118
+ });
119
+
120
+ it("send-to-back moves target below all", () => {
121
+ const { target, byId, parent } = makeFamily("3", [
122
+ ["a", "1"],
123
+ ["b", "5"],
124
+ ["c", "2"],
125
+ ]);
126
+ const patches = resolveZOrderPatches(target, "send-to-back");
127
+ // target must end up strictly below the current min (1) in render order.
128
+ expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
129
+ });
130
+
131
+ it("send-to-back returns null when already at back", () => {
132
+ const { target } = makeFamily("0", [
133
+ ["a", "1"],
134
+ ["b", "5"],
135
+ ["c", "3"],
136
+ ]);
137
+ expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
138
+ });
139
+
140
+ it("bring-forward steps up exactly one in render order", () => {
141
+ const { target, byId, parent } = makeFamily("2", [
142
+ ["a", "1"],
143
+ ["b", "4"],
144
+ ["c", "7"],
145
+ ]);
146
+ // render order bottom→top: a(1), target(2), b(4), c(7). forward → above b.
147
+ const patches = resolveZOrderPatches(target, "bring-forward");
148
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]);
149
+ });
150
+
151
+ it("send-backward steps down exactly one in render order", () => {
152
+ const { target, byId, parent } = makeFamily("5", [
153
+ ["a", "1"],
154
+ ["b", "3"],
155
+ ["c", "8"],
156
+ ]);
157
+ // bottom→top: a(1), b(3), target(5), c(8). backward → below b.
158
+ const patches = resolveZOrderPatches(target, "send-backward");
159
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]);
160
+ });
161
+
162
+ it("bring-forward returns null when already top of set", () => {
163
+ const { target } = makeFamily("8", [
164
+ ["a", "1"],
165
+ ["b", "4"],
166
+ ["c", "7"],
167
+ ]);
168
+ expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
169
+ });
170
+
171
+ it("send-backward returns null when already bottom of set", () => {
172
+ const { target } = makeFamily("0", [
173
+ ["a", "1"],
174
+ ["b", "3"],
175
+ ["c", "8"],
176
+ ]);
177
+ expect(resolveZOrderChange(target, "send-backward")).toBeNull();
178
+ });
179
+
180
+ it("returns null when no siblings", () => {
181
+ const target = makeEl("solo", "2");
182
+ document.createElement("div").appendChild(target);
183
+ for (const action of [
184
+ "bring-forward",
185
+ "send-backward",
186
+ "bring-to-front",
187
+ "send-to-back",
188
+ ] as ZOrderAction[]) {
189
+ expect(resolveZOrderChange(target, action)).toBeNull();
190
+ }
191
+ });
192
+ });
193
+
194
+ // ── DOM-order ties (the repro) ─────────────────────────────────────────────────
195
+
196
+ describe("resolveZOrderChange – DOM-order ties (repro: equal z)", () => {
197
+ it("send-backward: tied target LATER in DOM (visually on top) can go below", () => {
198
+ // img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints
199
+ // on top. send-backward must put target below the image.
200
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
201
+ const patches = resolveZOrderPatches(target, "send-backward");
202
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]);
203
+ // target ends strictly below the image.
204
+ const tz = patchFor(patches, byId, "target")?.zIndex ?? 0;
205
+ expect(tz).toBeGreaterThanOrEqual(0);
206
+ });
207
+
208
+ it("send-to-back: tied target LATER in DOM goes to the very back", () => {
209
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
210
+ const patches = resolveZOrderPatches(target, "send-to-back");
211
+ expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
212
+ });
213
+
214
+ it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => {
215
+ // target#target (z=0, earlier) then #a (z=0, later) → a paints on top.
216
+ // bring-forward on target must lift it above a.
217
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
218
+ const patches = resolveZOrderPatches(target, "bring-forward");
219
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]);
220
+ });
221
+
222
+ it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => {
223
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
224
+ const patches = resolveZOrderPatches(target, "bring-to-front");
225
+ const order = renderOrderIds(parent, byId, patches);
226
+ expect(order[order.length - 1]).toBe("target");
227
+ });
228
+
229
+ it("send-backward: tied target EARLIER in DOM is already at back → null", () => {
230
+ // target earlier + a later, both z=0. target already paints below a.
231
+ const { target } = makeFamily("0", [["a", "0"]]);
232
+ expect(resolveZOrderChange(target, "send-backward")).toBeNull();
233
+ expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
234
+ });
235
+
236
+ it("bring-forward: tied target LATER in DOM is already on top → null", () => {
237
+ const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
238
+ expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
239
+ expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
240
+ });
241
+
242
+ it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => {
243
+ // Three tied at z=0, target in the middle of DOM order. Sending it back must
244
+ // renumber to distinct values but leave the target (which keeps its bottom
245
+ // slot's value 0) unpatched — and every emitted patch must be a genuine change.
246
+ const parent = document.createElement("div");
247
+ const a = makeEl("a", "0");
248
+ const target = makeEl("target", "0");
249
+ const b = makeEl("b", "0");
250
+ parent.append(a, target, b);
251
+ const originalZ = new Map<HTMLElement, number>([
252
+ [a, 0],
253
+ [target, 0],
254
+ [b, 0],
255
+ ]);
256
+ // render order bottom→top by (z, dom): a, target, b. send-backward → below a.
257
+ const patches = resolveZOrderPatches(target, "send-backward");
258
+ // Every emitted patch is a REAL change: its new z differs from the old z.
259
+ for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element));
260
+ // target renumbers to 0 (its existing value) → it must NOT be in the patch set.
261
+ expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined();
262
+ // No two patches collide on the same element (a well-formed minimal set).
263
+ expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length);
264
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
265
+ expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]);
266
+ });
267
+ });
268
+
269
+ // ── overlap scoping (real getBoundingClientRect) ────────────────────────────────
270
+ //
271
+ // jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole
272
+ // family and the SCOPED (overlapping-only) path is never exercised above. These
273
+ // mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped.
274
+
275
+ interface Rect {
276
+ left: number;
277
+ top: number;
278
+ right: number;
279
+ bottom: number;
280
+ }
281
+ function setRect(el: HTMLElement, r: Rect): void {
282
+ el.getBoundingClientRect = (): DOMRect =>
283
+ ({
284
+ left: r.left,
285
+ top: r.top,
286
+ right: r.right,
287
+ bottom: r.bottom,
288
+ width: r.right - r.left,
289
+ height: r.bottom - r.top,
290
+ x: r.left,
291
+ y: r.top,
292
+ toJSON: () => ({}),
293
+ }) as DOMRect;
294
+ }
295
+
296
+ describe("resolveZOrderChange – overlap scoping preserves untouched non-scoped pairs (#2202)", () => {
297
+ it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => {
298
+ // A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT
299
+ // overlap target, so it is non-scoped. Old renumber sent the scoped set to
300
+ // 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting.
301
+ // The band-preserving renumber keeps the scoped block above C: A→6, C untouched.
302
+ const parent = document.createElement("div");
303
+ const a = makeEl("a", "5");
304
+ const target = makeEl("target", "5");
305
+ const c = makeEl("c", "3");
306
+ parent.append(a, target, c);
307
+ setRect(a, { left: 0, top: 0, right: 10, bottom: 10 });
308
+ setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
309
+ setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped
310
+ const byId = { a, target, c };
311
+
312
+ const patches = resolveZOrderPatches(target, "send-backward");
313
+ // C (untouched, non-scoped) is never patched.
314
+ expect(patchFor(patches, byId, "c")).toBeUndefined();
315
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
316
+ const order = renderOrderIds(parent, byId, []);
317
+ // Deliberate move: target below a. Preserved untouched pair: a stays above c.
318
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
319
+ expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c"));
320
+ });
321
+
322
+ it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => {
323
+ // target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring-
324
+ // forward must step target above a (its sole overlapping neighbour), NOT chase
325
+ // the non-overlapping far — proving the scoping actually runs with real rects.
326
+ const parent = document.createElement("div");
327
+ const target = makeEl("target", "1");
328
+ const a = makeEl("a", "2");
329
+ const far = makeEl("far", "5");
330
+ parent.append(target, a, far);
331
+ setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
332
+ setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target
333
+ setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint
334
+ const byId = { target, a, far };
335
+
336
+ const patches = resolveZOrderPatches(target, "bring-forward");
337
+ // far is untouched (not in the overlapping scope).
338
+ expect(patchFor(patches, byId, "far")).toBeUndefined();
339
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
340
+ const order = renderOrderIds(parent, byId, []);
341
+ // target rose just above its overlapping neighbour a, staying below far.
342
+ expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a"));
343
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("far"));
344
+ });
345
+ });
346
+
347
+ // ── non-painting sibling hygiene ───────────────────────────────────────────────
348
+
349
+ describe("resolveZOrderChange – excludes non-painting siblings", () => {
350
+ it("ignores <audio>/<script>/<style> siblings in the family", () => {
351
+ // Parent holds: img#a (z0), <audio> (a prior renumber wrote z=2 onto it),
352
+ // video#target (z0, later in DOM), plus a <script> and <style>. Only the two
353
+ // painting elements should form the family — the audio's z=2 must NOT pad the
354
+ // renumber or count as a sibling above the target.
355
+ const parent = document.createElement("div");
356
+ const a = makeEl("a", "0");
357
+ const audio = document.createElement("audio");
358
+ audio.style.zIndex = "2";
359
+ const script = document.createElement("script");
360
+ const style = document.createElement("style");
361
+ const target = makeEl("target", "0");
362
+ parent.append(a, audio, script, style, target);
363
+
364
+ // target is later in DOM than a, tied at z=0 → paints on top. send-to-back
365
+ // must put it below a. If audio (z=2) were counted, the renumber would differ.
366
+ const patches = resolveZOrderPatches(target, "send-to-back");
367
+ // No patch may target the audio/script/style elements.
368
+ for (const p of patches) {
369
+ expect(p.element).not.toBe(audio);
370
+ expect(p.element).not.toBe(script);
371
+ expect(p.element).not.toBe(style);
372
+ }
373
+ // Order among the painting pair: target below a.
374
+ const order = renderOrderIds(parent, { a, target }, patches);
375
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
376
+ });
377
+
378
+ it("a lone painting element beside only non-painting siblings has no family → null", () => {
379
+ const parent = document.createElement("div");
380
+ const target = makeEl("target", "1");
381
+ const audio = document.createElement("audio");
382
+ parent.append(target, audio);
383
+ // Only sibling is <audio> (excluded) → family size 1 → every action is a no-op.
384
+ for (const action of [
385
+ "bring-forward",
386
+ "send-backward",
387
+ "bring-to-front",
388
+ "send-to-back",
389
+ ] as ZOrderAction[]) {
390
+ expect(resolveZOrderChange(target, action)).toBeNull();
391
+ }
392
+ });
393
+ });
394
+
395
+ // ── isZOrderActionEnabled ─────────────────────────────────────────────────────
396
+
397
+ describe("isZOrderActionEnabled", () => {
398
+ it("mirrors resolveZOrderChange non-null", () => {
399
+ // target z=2 (DOM 0), a z=5 (DOM 1): render order = target, a. target is at
400
+ // the bottom, so forward/front are enabled and backward/back are no-ops.
401
+ const { target } = makeFamily("2", [["a", "5"]]);
402
+ expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(true);
403
+ expect(isZOrderActionEnabled(target, "bring-forward")).toBe(true);
404
+ expect(isZOrderActionEnabled(target, "send-to-back")).toBe(false);
405
+ expect(isZOrderActionEnabled(target, "send-backward")).toBe(false);
406
+ });
407
+
408
+ it("false when already on top", () => {
409
+ const { target } = makeFamily("6", [
410
+ ["a", "1"],
411
+ ["b", "5"],
412
+ ]);
413
+ expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(false);
414
+ expect(isZOrderActionEnabled(target, "bring-forward")).toBe(false);
415
+ });
416
+
417
+ it("tie repro: send-backward enabled for a visually-on-top tied target", () => {
418
+ const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
419
+ expect(isZOrderActionEnabled(target, "send-backward")).toBe(true);
420
+ expect(isZOrderActionEnabled(target, "send-to-back")).toBe(true);
421
+ });
422
+
423
+ it("all actions disabled when there are no siblings", () => {
424
+ const target = makeEl("solo", "1");
425
+ document.createElement("div").appendChild(target);
426
+ for (const action of [
427
+ "bring-forward",
428
+ "send-backward",
429
+ "bring-to-front",
430
+ "send-to-back",
431
+ ] as ZOrderAction[]) {
432
+ expect(isZOrderActionEnabled(target, action)).toBe(false);
433
+ }
434
+ });
435
+ });