@hyperframes/studio 0.7.53 → 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-CJpl5RTK.js → index-BRwkMj0w.js} +108 -108
- package/dist/assets/{index-DHrXh-VF.js → index-BXaqaVKt.js} +1 -1
- package/dist/assets/{index-Bo8sRL2U.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,244 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
|
|
3
|
+
|
|
4
|
+
function el(
|
|
5
|
+
key: string,
|
|
6
|
+
track: number,
|
|
7
|
+
start: number,
|
|
8
|
+
duration: number,
|
|
9
|
+
zIndex: number,
|
|
10
|
+
isAudio = false,
|
|
11
|
+
domIndex?: number,
|
|
12
|
+
): StackingElement {
|
|
13
|
+
return { key, track, start, duration, zIndex, isAudio, domIndex };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function patchMap(elements: StackingElement[], edited: string[]): Record<string, number> {
|
|
17
|
+
const out: Record<string, number> = {};
|
|
18
|
+
for (const p of computeStackingPatches(elements, edited)) out[p.key] = p.zIndex;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("laneIsAbove", () => {
|
|
23
|
+
it("lower track renders above (top of timeline wins)", () => {
|
|
24
|
+
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
|
|
25
|
+
expect(laneIsAbove({ track: 2 }, { track: 1 })).toBe(false);
|
|
26
|
+
expect(laneIsAbove({ track: 1 }, { track: 1 })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("computeStackingPatches", () => {
|
|
31
|
+
it("no overlapping clips → no patch", () => {
|
|
32
|
+
// a (0..5 on track 0) and b (10..15 on track 1) never overlap in time.
|
|
33
|
+
const elements = [el("a", 0, 0, 5, 10), el("b", 1, 10, 5, 5)];
|
|
34
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("edited clip moved to a HIGHER lane (top) but z too low → raised above the below-neighbour", () => {
|
|
38
|
+
// a on top lane (0) overlaps b on lane 1; a.z=1 is below b.z=5 → wrong.
|
|
39
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
|
|
40
|
+
// Only a is edited → only a gets a patch, lifting it above b (5) → 6.
|
|
41
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("edited clip moved to a LOWER lane (bottom) but z too high → lowered below the above-neighbour", () => {
|
|
45
|
+
// a on lane 2 (bottom) overlaps b on lane 0 (top); a.z=9 above b.z=5 → wrong.
|
|
46
|
+
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 5)];
|
|
47
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("edited clip already correctly ordered → no patch (authored z preserved)", () => {
|
|
51
|
+
// a on top lane already has higher z than the lower-lane b it overlaps.
|
|
52
|
+
const elements = [el("a", 0, 0, 10, 8), el("b", 1, 0, 10, 3)];
|
|
53
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("untouched clips never get a patch even when they overlap the edit", () => {
|
|
57
|
+
// b is out of order relative to a, but a is the only edited clip.
|
|
58
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5), el("c", 2, 0, 10, 9)];
|
|
59
|
+
const patches = computeStackingPatches(elements, ["a"]);
|
|
60
|
+
expect(patches.map((p) => p.key)).toEqual(["a"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("sits strictly between neighbours when there is integer room", () => {
|
|
64
|
+
// edited a on middle lane 1 between below-lane-2 (z=2) and above-lane-0 (z=10).
|
|
65
|
+
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 2), el("above", 0, 0, 10, 10)];
|
|
66
|
+
// Between 2 and 10 → floor((2+10)/2)=6.
|
|
67
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("adjacent neighbours (no integer gap) → edited lands above lower, upper is bumped", () => {
|
|
71
|
+
// below z=4, above z=5 (adjacent). There is no integer strictly between 4 and
|
|
72
|
+
// 5, so the old single-patch a=5 left `a` TIED with `above` — and with no DOM
|
|
73
|
+
// order to break the tie, `above` no longer paints strictly above `a` (the
|
|
74
|
+
// under-patch bug). Tie-aware cascade: a→5 (above below's 4) AND above→6 so it
|
|
75
|
+
// stays strictly on top. Minimal: only the two overlapping neighbours move.
|
|
76
|
+
const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 4), el("above", 0, 0, 10, 5)];
|
|
77
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 5, above: 6 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("audio clips are excluded — an audio edit yields no patch", () => {
|
|
81
|
+
const elements = [el("music", 3, 0, 10, 0, true), el("v", 0, 0, 10, 5)];
|
|
82
|
+
expect(patchMap(elements, ["music"])).toEqual({});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("audio clips are excluded as neighbours — a visual edit ignores overlapping audio", () => {
|
|
86
|
+
// The only overlapping clip is audio → treated as no visual overlap → no patch.
|
|
87
|
+
const elements = [el("v", 0, 0, 10, 3), el("music", 3, 0, 10, 99, true)];
|
|
88
|
+
expect(patchMap(elements, ["v"])).toEqual({});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("only-below neighbours → maxBelow + 1", () => {
|
|
92
|
+
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 3), el("c", 2, 0, 10, 7)];
|
|
93
|
+
// a on top overlaps b(3) and c(7), both below → 7+1=8.
|
|
94
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 8 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("only-above neighbours → minAbove - 1 (clamped ≥ 0)", () => {
|
|
98
|
+
const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 1), el("c", 1, 0, 10, 4)];
|
|
99
|
+
// a on bottom overlaps b(1) and c(4), both above → min(1)-1=0.
|
|
100
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 0 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("partial time overlap still counts", () => {
|
|
104
|
+
// a: 0..6, b: 5..15 overlap in [5,6).
|
|
105
|
+
const elements = [el("a", 0, 0, 6, 1), el("b", 1, 5, 10, 5)];
|
|
106
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("touching-but-not-overlapping intervals do NOT count", () => {
|
|
110
|
+
// a ends exactly where b starts (t=5) → half-open, no overlap.
|
|
111
|
+
const elements = [el("a", 0, 0, 5, 1), el("b", 1, 5, 5, 5)];
|
|
112
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("multi-clip edit: two dragged clips resolve consistently against the region", () => {
|
|
116
|
+
// Drag a (lane 0) and b (lane 1) onto a region already holding c (lane 2, z=5).
|
|
117
|
+
// Both overlap c. Lower-lane b resolves first (above c → 6), then a (above b → 7).
|
|
118
|
+
const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
|
|
119
|
+
expect(patchMap(elements, ["a", "b"])).toEqual({ a: 7, b: 6 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("multi-clip edit skips a member that is already correctly ordered", () => {
|
|
123
|
+
const elements = [el("a", 0, 0, 10, 20), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
|
|
124
|
+
// a(20) already above everything → no patch. b (lane 1) sits between
|
|
125
|
+
// below-neighbour c(5) and above-neighbour a(20) → floor((5+20)/2)=12.
|
|
126
|
+
expect(patchMap(elements, ["a", "b"])).toEqual({ b: 12 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("empty edited set → no patches", () => {
|
|
130
|
+
const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
|
|
131
|
+
expect(computeStackingPatches(elements, [])).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("item 13: an unresolved neighbour (non-finite z) is EXCLUDED, not treated as z=0", () => {
|
|
135
|
+
// `ghost` is an overlapping upper-lane clip whose live node could not be
|
|
136
|
+
// resolved, so its z came back NaN. If it were fabricated to 0 it would be a
|
|
137
|
+
// real above-neighbour at the z-floor and drag the edited clip's cascade down.
|
|
138
|
+
// Excluded, the only real overlap is below-neighbour b(3) → a rises to 4.
|
|
139
|
+
const elements = [
|
|
140
|
+
el("a", 1, 0, 10, 0),
|
|
141
|
+
el("b", 2, 0, 10, 3),
|
|
142
|
+
el("ghost", 0, 0, 10, Number.NaN),
|
|
143
|
+
];
|
|
144
|
+
expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("item 13: an edited clip whose own z is unresolved (non-finite) yields no patch", () => {
|
|
148
|
+
// The edited clip itself couldn't be resolved → it is dropped from the working
|
|
149
|
+
// set and produces nothing (no fabricated-0 self-patch).
|
|
150
|
+
const elements = [el("a", 0, 0, 10, Number.NaN), el("b", 1, 0, 10, 5)];
|
|
151
|
+
expect(patchMap(elements, ["a"])).toEqual({});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("computeStackingPatches — tie-aware cascade (lane move always realisable)", () => {
|
|
156
|
+
it("drag below an overlapping z=0 neighbour → cascade bumps the neighbour, edit→0", () => {
|
|
157
|
+
// edited `v` (z=2) dragged to the BOTTOM lane, overlapping `r` (z=0) which is
|
|
158
|
+
// now on the upper lane. No z ≥ 0 fits strictly below 0, so the old resolver
|
|
159
|
+
// clamped v to 0 (tied with r) and nothing changed on canvas — the reported
|
|
160
|
+
// bug. Tie-aware: v→0 AND r bumped to 1 so r paints strictly above v.
|
|
161
|
+
const elements = [el("v", 1, 0, 10, 2), el("r", 0, 0, 10, 0)];
|
|
162
|
+
expect(patchMap(elements, ["v"])).toEqual({ v: 0, r: 1 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("equal-z + domIndex: dragging the LATER-in-DOM clip below is realised via a bump", () => {
|
|
166
|
+
// Two equal-z clips; `v` is later in DOM (domIndex 1) so it currently paints
|
|
167
|
+
// ON TOP of `r` (domIndex 0). User drags v to the lower lane (track 1). With
|
|
168
|
+
// domIndex the sync SEES that v is currently above r and must be lowered:
|
|
169
|
+
// v→0 (already 0, stays) then r bumped to 1 so r wins. Without domIndex the
|
|
170
|
+
// equal z would look already-correct and under-patch.
|
|
171
|
+
const elements = [el("r", 0, 0, 10, 0, false, 0), el("v", 1, 0, 10, 0, false, 1)];
|
|
172
|
+
expect(patchMap(elements, ["v"])).toEqual({ r: 1 });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("equal-z without domIndex is ambiguous → conservatively bumps to guarantee order", () => {
|
|
176
|
+
// Same shape but NO domIndex: equal z is ambiguous, so the resolver cannot
|
|
177
|
+
// prove v is already below r and patches to make the order explicit (r above).
|
|
178
|
+
const elements = [el("r", 0, 0, 10, 0), el("v", 1, 0, 10, 0)];
|
|
179
|
+
const out = patchMap(elements, ["v"]);
|
|
180
|
+
// r must end up strictly above v (higher z) regardless of the exact numbers.
|
|
181
|
+
const vz = out.v ?? 0;
|
|
182
|
+
const rz = out.r ?? 0;
|
|
183
|
+
expect(rz).toBeGreaterThan(vz);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("#2198 (Abhai repro): a lift cascades transitively so an UNTOUCHED pair never inverts", () => {
|
|
187
|
+
// m (z1, lane0, dom0), n (z0, lane1, dom1), e (z2, lane2, dom2, edited).
|
|
188
|
+
// e overlaps n [5,10); n overlaps m [12,15); e does NOT overlap m.
|
|
189
|
+
// Dragging e to the bottom lane forces n up to paint above e. A naive lift sets
|
|
190
|
+
// n→1, which TIES m (z1) and — n being later in the DOM — paints n above m,
|
|
191
|
+
// inverting the untouched (m,n) pair (which the next normalize would reshuffle).
|
|
192
|
+
// The transitive cascade lifts m too (→2) so m stays strictly above n.
|
|
193
|
+
const elements = [
|
|
194
|
+
el("m", 0, 12, 8, 1, false, 0),
|
|
195
|
+
el("n", 1, 5, 10, 0, false, 1),
|
|
196
|
+
el("e", 2, 0, 10, 2, false, 2),
|
|
197
|
+
];
|
|
198
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 0, n: 1, m: 2 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("cascade patches as FEW clips as possible (only the blockers move)", () => {
|
|
202
|
+
// v dragged to bottom under r(z0) and s(z0) both on higher lanes; a distant
|
|
203
|
+
// non-overlapping clip x is never touched.
|
|
204
|
+
const elements = [
|
|
205
|
+
el("v", 2, 0, 10, 5),
|
|
206
|
+
el("r", 0, 0, 10, 0),
|
|
207
|
+
el("s", 1, 0, 10, 0),
|
|
208
|
+
el("x", 3, 50, 10, 0), // no time overlap → untouched
|
|
209
|
+
];
|
|
210
|
+
const out = patchMap(elements, ["v"]);
|
|
211
|
+
expect("x" in out).toBe(false);
|
|
212
|
+
// v below both r and s.
|
|
213
|
+
expect(out.v).toBeLessThan(out.r ?? 0);
|
|
214
|
+
expect(out.v).toBeLessThan(out.s ?? 0);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("computeStackingPatches — DOM tie-break gates the cascade (item 12)", () => {
|
|
219
|
+
it("tie ACCEPTABLE: edited may sit AT minAbove when the above-neighbour is later in DOM → single patch, no neighbour bump", () => {
|
|
220
|
+
// below b (z3, lane2, dom0), edited e (lane1, dom1), above a (z4, lane0, dom2).
|
|
221
|
+
// e must paint above b and below a. There is no integer strictly between 3 and
|
|
222
|
+
// 4, but a is LATER in the DOM, so e=4 ties a and a still paints on top by DOM
|
|
223
|
+
// order — a valid SINGLE patch. The old gap<2 rule cascaded and needlessly
|
|
224
|
+
// bumped a's authored z (the over-patch). Only e changes here.
|
|
225
|
+
const elements = [
|
|
226
|
+
el("b", 2, 0, 10, 3, false, 0),
|
|
227
|
+
el("e", 1, 0, 10, 0, false, 1),
|
|
228
|
+
el("a", 0, 0, 10, 4, false, 2),
|
|
229
|
+
];
|
|
230
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 4 });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("tie INVERTING: edited tying minAbove would paint ABOVE it (earlier in DOM) → cascade bumps the neighbour", () => {
|
|
234
|
+
// Same z's, but a is EARLIER in the DOM than e (dom0 vs dom2). Now e=4 would
|
|
235
|
+
// tie a AND paint on top (e later in DOM), violating the lane order, so the
|
|
236
|
+
// tie-break can't save it: e→4 and a is bumped to 5.
|
|
237
|
+
const elements = [
|
|
238
|
+
el("a", 0, 0, 10, 4, false, 0),
|
|
239
|
+
el("b", 2, 0, 10, 3, false, 1),
|
|
240
|
+
el("e", 1, 0, 10, 0, false, 2),
|
|
241
|
+
];
|
|
242
|
+
expect(patchMap(elements, ["e"])).toEqual({ e: 4, a: 5 });
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timelineStackingSync — lane ↔ stacking unification (pure).
|
|
3
|
+
*
|
|
4
|
+
* The approved design: **lane order implies stacking**. A clip on a higher lane
|
|
5
|
+
* (rendered ABOVE another in the timeline) should render ON TOP of any clip it
|
|
6
|
+
* OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
|
|
7
|
+
* edit, and ONLY for the clip(s) the user actually edited.
|
|
8
|
+
*
|
|
9
|
+
* Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
|
|
10
|
+
* tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
|
|
11
|
+
* value renders HIGHER on screen. Standard NLE convention = the top row wins,
|
|
12
|
+
* therefore **lower track ⇒ higher z-index**. We express this with a single
|
|
13
|
+
* comparator so callers never have to remember the polarity.
|
|
14
|
+
*
|
|
15
|
+
* This module is DOM-free and store-free. Callers project their world onto
|
|
16
|
+
* `StackingElement` (supplying the live z-index they read from the DOM/inline
|
|
17
|
+
* style) and apply the returned `StackingPatch[]` however they persist styles.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Minimal element view this module reasons over. */
|
|
21
|
+
export interface StackingElement {
|
|
22
|
+
/** Stable identity (TimelineElement.key ?? id). */
|
|
23
|
+
key: string;
|
|
24
|
+
/** Absolute start time (seconds). */
|
|
25
|
+
start: number;
|
|
26
|
+
/** Duration (seconds). */
|
|
27
|
+
duration: number;
|
|
28
|
+
/**
|
|
29
|
+
* Display lane (the normalized timeline `track`). Lower = higher on screen =
|
|
30
|
+
* should stack on top. This is the post-edit lane for edited clips.
|
|
31
|
+
*/
|
|
32
|
+
track: number;
|
|
33
|
+
/**
|
|
34
|
+
* Current z-index (parsed from inline style / computed; "auto" ⇒ 0), or a
|
|
35
|
+
* NON-FINITE value (NaN) when the caller could NOT resolve the clip's live node
|
|
36
|
+
* (e.g. an unmounted / nested sub-comp element, or one outside the active file).
|
|
37
|
+
* A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
|
|
38
|
+
* neighbour nor resolvable as an edit — so an unresolved node never fabricates a
|
|
39
|
+
* z=0 neighbour that poisons the boundary math (item 13). The reader signals a
|
|
40
|
+
* miss with NaN rather than null so the value stays assignable to the existing
|
|
41
|
+
* `(el) => number` reader contract the drag hook / commit deps declare.
|
|
42
|
+
*/
|
|
43
|
+
zIndex: number;
|
|
44
|
+
/** Audio clips have no visual stacking and are excluded from the computation. */
|
|
45
|
+
isAudio: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
|
|
48
|
+
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
|
|
49
|
+
* B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can
|
|
50
|
+
* under-patch (the reported bug: a clip dragged to the bottom lane over an
|
|
51
|
+
* equal-z neighbour changed nothing on canvas). Callers pass the index of the
|
|
52
|
+
* element in the discovery order array.
|
|
53
|
+
*/
|
|
54
|
+
domIndex?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A minimal z-index change for one clip. */
|
|
58
|
+
export interface StackingPatch {
|
|
59
|
+
key: string;
|
|
60
|
+
zIndex: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EPS = 1e-6;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Two clips overlap in time when their half-open [start, end) intervals intersect.
|
|
67
|
+
*
|
|
68
|
+
* NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
|
|
69
|
+
* strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so
|
|
70
|
+
* exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the
|
|
71
|
+
* epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two
|
|
72
|
+
* abutting clips and shuffling lanes. The two are intended to differ, not align.
|
|
73
|
+
*/
|
|
74
|
+
function overlapsInTime(a: StackingElement, b: StackingElement): boolean {
|
|
75
|
+
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Is `a` visually ABOVE `b` (should stack on top)? Lower track renders higher on
|
|
80
|
+
* screen, so a lower track number means "above". Exposed for tests / callers.
|
|
81
|
+
*/
|
|
82
|
+
export function laneIsAbove(
|
|
83
|
+
a: Pick<StackingElement, "track">,
|
|
84
|
+
b: Pick<StackingElement, "track">,
|
|
85
|
+
): boolean {
|
|
86
|
+
return a.track < b.track;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
|
|
91
|
+
* the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
|
|
92
|
+
* whose z could not be resolved (null) are dropped before this stage.
|
|
93
|
+
*/
|
|
94
|
+
interface MutZ extends StackingElement {
|
|
95
|
+
zIndex: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
|
|
100
|
+
* order (later in DOM paints on top). When either domIndex is absent, equal z is
|
|
101
|
+
* treated as "not strictly above" (ambiguous) — callers should supply domIndex to
|
|
102
|
+
* disambiguate (see StackingElement.domIndex). Operates on resolved (`MutZ`) clips.
|
|
103
|
+
*/
|
|
104
|
+
function paintsAbove(a: MutZ, b: MutZ): boolean {
|
|
105
|
+
if (a.zIndex !== b.zIndex) return a.zIndex > b.zIndex;
|
|
106
|
+
if (a.domIndex != null && b.domIndex != null) return a.domIndex > b.domIndex;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
|
|
111
|
+
function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
|
|
112
|
+
return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
|
|
117
|
+
* matches its lane order (lower lane ⇒ paints on top). Records every z change
|
|
118
|
+
* (edited clip AND any neighbours that must be bumped) into `patchZ`.
|
|
119
|
+
*
|
|
120
|
+
* Fast path (unchanged behaviour): when a single non-negative z for the edited
|
|
121
|
+
* clip alone realises the order — strictly between the neighbours if there is
|
|
122
|
+
* integer room, else just above the lower neighbour, else just below the upper —
|
|
123
|
+
* emit only that. This keeps every existing single-patch test passing.
|
|
124
|
+
*
|
|
125
|
+
* Cascade path: when ties/clamping make the single-clip patch impossible or
|
|
126
|
+
* ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
|
|
127
|
+
* equal-z neighbours where DOM order alone can't express it), bump the minimum set
|
|
128
|
+
* of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
|
|
129
|
+
* needed) so the edited clip's intended lane order is realised with all z ≥ 0.
|
|
130
|
+
* "Authored z sacred" stays the default — neighbours are touched only when the
|
|
131
|
+
* user's explicit lane move is otherwise inexpressible (same precedent as the
|
|
132
|
+
* canvas context-menu tie-aware fix).
|
|
133
|
+
*
|
|
134
|
+
* Returns true when any z changed (recorded in `patchZ`), false for a no-op.
|
|
135
|
+
*/
|
|
136
|
+
function resolveEditedZ(
|
|
137
|
+
edited: MutZ,
|
|
138
|
+
overlapping: MutZ[],
|
|
139
|
+
overlappersOf: (clip: MutZ) => MutZ[],
|
|
140
|
+
patchZ: (clip: MutZ, z: number) => void,
|
|
141
|
+
): boolean {
|
|
142
|
+
const visualOverlap = overlapping.filter((o) => !o.isAudio);
|
|
143
|
+
if (visualOverlap.length === 0) return false;
|
|
144
|
+
|
|
145
|
+
// Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
|
|
146
|
+
const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
|
|
147
|
+
const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
|
|
148
|
+
|
|
149
|
+
// Already correct against every overlapping neighbour → no-op (authored z kept).
|
|
150
|
+
const correct =
|
|
151
|
+
below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
|
|
152
|
+
if (correct) return false;
|
|
153
|
+
|
|
154
|
+
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
155
|
+
|
|
156
|
+
// ── Fast path: try to realise the order by moving only `edited`. ──────────────
|
|
157
|
+
const single = trySingleZ(edited, below, above);
|
|
158
|
+
if (single != null) {
|
|
159
|
+
if (single !== edited.zIndex) patchZ(edited, single);
|
|
160
|
+
// Even at an unchanged z the DOM-order ties may already be satisfied; if not,
|
|
161
|
+
// `trySingleZ` returned null and we fall through to the cascade.
|
|
162
|
+
return single !== edited.zIndex;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
|
|
166
|
+
// Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
|
|
167
|
+
// above-neighbours that are now not strictly above, minimally, one step past it.
|
|
168
|
+
const target = maxBelow != null ? maxBelow + 1 : 0;
|
|
169
|
+
const clamped = Math.max(0, target);
|
|
170
|
+
if (clamped !== edited.zIndex) patchZ(edited, clamped);
|
|
171
|
+
liftAbove(edited, overlappersOf, patchZ);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pick a single non-negative z for `edited` that lands it correctly against its
|
|
177
|
+
* neighbours (paints above every below-neighbour, below every above-neighbour), or
|
|
178
|
+
* null when no such z exists and the caller must cascade.
|
|
179
|
+
*
|
|
180
|
+
* The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
|
|
181
|
+
* (z + DOM tie-break), so an authored z that already paints correctly by DOM order
|
|
182
|
+
* is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
|
|
183
|
+
* that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
|
|
184
|
+
* paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
|
|
185
|
+
* When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
|
|
186
|
+
* fails verification and the caller cascades.
|
|
187
|
+
*/
|
|
188
|
+
// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
|
|
189
|
+
// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
|
|
190
|
+
// else the caller cascades.
|
|
191
|
+
function zBetweenNeighbours(
|
|
192
|
+
maxBelow: number,
|
|
193
|
+
minAbove: number,
|
|
194
|
+
correctAt: (z: number) => boolean,
|
|
195
|
+
): number | null {
|
|
196
|
+
if (minAbove - maxBelow >= 2) {
|
|
197
|
+
const mid = Math.floor((maxBelow + minAbove) / 2);
|
|
198
|
+
return mid > maxBelow && mid < minAbove ? mid : null;
|
|
199
|
+
}
|
|
200
|
+
return correctAt(minAbove) ? minAbove : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// edited has only above-neighbours: sit one step below minAbove, or at the z=0
|
|
204
|
+
// floor tie minAbove when a DOM tie-break keeps that neighbour on top.
|
|
205
|
+
function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
|
|
206
|
+
const candidate = minAbove - 1;
|
|
207
|
+
if (candidate >= 0) return candidate;
|
|
208
|
+
return correctAt(minAbove) ? minAbove : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
|
|
212
|
+
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
213
|
+
const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
|
|
214
|
+
|
|
215
|
+
const correctAt = (z: number): boolean => {
|
|
216
|
+
const probe: MutZ = { ...edited, zIndex: z };
|
|
217
|
+
return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (maxBelow != null && minAbove != null)
|
|
221
|
+
return zBetweenNeighbours(maxBelow, minAbove, correctAt);
|
|
222
|
+
if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
|
|
223
|
+
if (minAbove != null) return zBelowOnly(minAbove, correctAt);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
|
|
229
|
+
* lane paints on top — starting from `edited` and cascading TRANSITIVELY.
|
|
230
|
+
*
|
|
231
|
+
* Seeded with `edited`: each of its upper-lane overlappers must paint strictly
|
|
232
|
+
* above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
|
|
233
|
+
* clip it overlaps that sits on an even higher lane — that clip must be lifted too,
|
|
234
|
+
* and so on. Without the cascade a lifted neighbour could tie an untouched clip on
|
|
235
|
+
* a higher lane and, being later in the DOM, paint above it — an untouched pair
|
|
236
|
+
* visibly inverting (#2198). The condition is LANE order (not "was originally
|
|
237
|
+
* above"), so a clip that was already violating lane order — e.g. a bottom-lane
|
|
238
|
+
* clip painting on top — is fixed, never preserved. Only clips whose z actually
|
|
239
|
+
* changes are patched; z climbs by +1 each step so the walk terminates.
|
|
240
|
+
*/
|
|
241
|
+
function liftAbove(
|
|
242
|
+
edited: MutZ,
|
|
243
|
+
overlappersOf: (clip: MutZ) => MutZ[],
|
|
244
|
+
patchZ: (clip: MutZ, z: number) => void,
|
|
245
|
+
): void {
|
|
246
|
+
const queue: MutZ[] = [edited];
|
|
247
|
+
const raiseAbove = (clip: MutZ, floor: MutZ): void => {
|
|
248
|
+
if (paintsAbove(clip, floor)) return; // already strictly on top
|
|
249
|
+
patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
|
|
250
|
+
queue.push(clip);
|
|
251
|
+
};
|
|
252
|
+
while (queue.length > 0) {
|
|
253
|
+
const floor = queue.shift()!;
|
|
254
|
+
for (const other of overlappersOf(floor)) {
|
|
255
|
+
if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Compute z-index patches so each edited clip's stacking matches its lane order.
|
|
262
|
+
*
|
|
263
|
+
* @param elements The FULL post-edit element set (edited clips already carry
|
|
264
|
+
* their new lane/time). Untouched clips keep their current z.
|
|
265
|
+
* @param editedKeys Keys of the clip(s) the user just edited.
|
|
266
|
+
* @returns Minimal z patches. When a single-clip patch realises the order it is
|
|
267
|
+
* the only patch (authored z of neighbours untouched); when ties or a
|
|
268
|
+
* z=0 floor make that impossible, the minimum set of overlapping
|
|
269
|
+
* neighbours is bumped too so the lane move is always realisable with
|
|
270
|
+
* all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
|
|
271
|
+
*
|
|
272
|
+
* Multi-clip edits: each edited clip is resolved against the CURRENT (already-
|
|
273
|
+
* patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
|
|
274
|
+
* region stacks consistently.
|
|
275
|
+
*/
|
|
276
|
+
export function computeStackingPatches(
|
|
277
|
+
elements: StackingElement[],
|
|
278
|
+
editedKeys: Iterable<string>,
|
|
279
|
+
): StackingPatch[] {
|
|
280
|
+
const editedSet = new Set(editedKeys);
|
|
281
|
+
if (editedSet.size === 0) return [];
|
|
282
|
+
|
|
283
|
+
// Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
|
|
284
|
+
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
|
|
285
|
+
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
|
|
286
|
+
// excluded outright (item 13).
|
|
287
|
+
const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
|
288
|
+
|
|
289
|
+
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
|
|
290
|
+
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
|
|
291
|
+
const edited = resolved
|
|
292
|
+
.filter((e) => editedSet.has(e.key) && !e.isAudio)
|
|
293
|
+
.map((e) => byKey.get(e.key)!)
|
|
294
|
+
// Resolve lower-lane (renders below) clips first so their new z is visible
|
|
295
|
+
// to higher-lane siblings resolved after them.
|
|
296
|
+
.sort((a, b) => b.track - a.track);
|
|
297
|
+
|
|
298
|
+
const changed = new Map<string, number>();
|
|
299
|
+
const patchZ = (clip: MutZ, z: number): void => {
|
|
300
|
+
clip.zIndex = z;
|
|
301
|
+
changed.set(clip.key, z);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// The full live set, so the transitive cascade can reach clips that overlap a
|
|
305
|
+
// LIFTED neighbour without overlapping the edited clip itself (#2198).
|
|
306
|
+
const all = [...byKey.values()];
|
|
307
|
+
const overlappersOf = (clip: MutZ): MutZ[] =>
|
|
308
|
+
all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
|
|
309
|
+
|
|
310
|
+
for (const clip of edited) {
|
|
311
|
+
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Emit in a stable order (edited clips first in their resolve order, then any
|
|
315
|
+
// cascaded neighbours) — deterministic for tests and undo grouping.
|
|
316
|
+
const emitted = new Set<string>();
|
|
317
|
+
const patches: StackingPatch[] = [];
|
|
318
|
+
for (const clip of edited) {
|
|
319
|
+
if (changed.has(clip.key) && !emitted.has(clip.key)) {
|
|
320
|
+
patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
|
|
321
|
+
emitted.add(clip.key);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const [key, zIndex] of changed) {
|
|
325
|
+
if (!emitted.has(key)) {
|
|
326
|
+
patches.push({ key, zIndex });
|
|
327
|
+
emitted.add(key);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return patches;
|
|
331
|
+
}
|