@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,437 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import {
|
|
4
|
+
clampTrackToZone,
|
|
5
|
+
isInsertAllowedForZone,
|
|
6
|
+
isLaneFree,
|
|
7
|
+
resolveInsertRow,
|
|
8
|
+
resolvePlacement,
|
|
9
|
+
resolveZoneDropPlacement,
|
|
10
|
+
timeRangesOverlap,
|
|
11
|
+
} from "./timelineCollision";
|
|
12
|
+
|
|
13
|
+
function el(id: string, track: number, start: number, duration: number): TimelineElement {
|
|
14
|
+
return { id, tag: "video", start, duration, track };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("timeRangesOverlap", () => {
|
|
18
|
+
it("detects overlap and treats touching edges as free (half-open)", () => {
|
|
19
|
+
expect(timeRangesOverlap(0, 2, 1, 3)).toBe(true);
|
|
20
|
+
expect(timeRangesOverlap(0, 2, 2, 4)).toBe(false); // touching at 2
|
|
21
|
+
expect(timeRangesOverlap(2, 4, 0, 2)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("isLaneFree", () => {
|
|
26
|
+
const els = [el("a", 0, 0, 5), el("b", 1, 2, 3)];
|
|
27
|
+
|
|
28
|
+
it("is free when nothing overlaps on the track", () => {
|
|
29
|
+
expect(isLaneFree(els, 2, 0, 5, null)).toBe(true);
|
|
30
|
+
expect(isLaneFree(els, 0, 6, 8, null)).toBe(true); // same track, no time overlap
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is occupied when a clip overlaps on the same track", () => {
|
|
34
|
+
expect(isLaneFree(els, 0, 1, 3, null)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("ignores the excluded (dragged) clip", () => {
|
|
38
|
+
expect(isLaneFree(els, 0, 1, 3, "a")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("resolvePlacement", () => {
|
|
43
|
+
const trackOrder = [0, 1, 2, 3];
|
|
44
|
+
|
|
45
|
+
it("keeps the desired lane when it is free", () => {
|
|
46
|
+
const els = [el("a", 2, 0, 4)];
|
|
47
|
+
expect(
|
|
48
|
+
resolvePlacement({
|
|
49
|
+
elements: els,
|
|
50
|
+
desiredTrack: 1,
|
|
51
|
+
start: 0,
|
|
52
|
+
duration: 4,
|
|
53
|
+
trackOrder,
|
|
54
|
+
excludeKey: null,
|
|
55
|
+
}),
|
|
56
|
+
).toEqual({
|
|
57
|
+
track: 1,
|
|
58
|
+
needsInsert: false,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("pushes up to the nearest free lane above when the target is occupied", () => {
|
|
63
|
+
// desired = 2 occupied; 1 free above → land on 1
|
|
64
|
+
const els = [el("blocker", 2, 0, 4)];
|
|
65
|
+
expect(
|
|
66
|
+
resolvePlacement({
|
|
67
|
+
elements: els,
|
|
68
|
+
desiredTrack: 2,
|
|
69
|
+
start: 1,
|
|
70
|
+
duration: 2,
|
|
71
|
+
trackOrder,
|
|
72
|
+
excludeKey: null,
|
|
73
|
+
}),
|
|
74
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("prefers up even when a lane below is also free", () => {
|
|
78
|
+
// desired 2 occupied; both 1 (up) and 3 (down) free → up wins
|
|
79
|
+
const els = [el("blocker", 2, 0, 5)];
|
|
80
|
+
expect(
|
|
81
|
+
resolvePlacement({
|
|
82
|
+
elements: els,
|
|
83
|
+
desiredTrack: 2,
|
|
84
|
+
start: 0,
|
|
85
|
+
duration: 3,
|
|
86
|
+
trackOrder,
|
|
87
|
+
excludeKey: null,
|
|
88
|
+
}),
|
|
89
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls to a lane below when every lane above is occupied", () => {
|
|
93
|
+
// desired 1 occupied; 0 occupied above; 2 free below → land on 2
|
|
94
|
+
const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)];
|
|
95
|
+
expect(
|
|
96
|
+
resolvePlacement({
|
|
97
|
+
elements: els,
|
|
98
|
+
desiredTrack: 1,
|
|
99
|
+
start: 1,
|
|
100
|
+
duration: 2,
|
|
101
|
+
trackOrder,
|
|
102
|
+
excludeKey: null,
|
|
103
|
+
}),
|
|
104
|
+
).toEqual({ track: 2, needsInsert: false });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("signals needsInsert when no lane is free", () => {
|
|
108
|
+
const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)];
|
|
109
|
+
expect(
|
|
110
|
+
resolvePlacement({
|
|
111
|
+
elements: els,
|
|
112
|
+
desiredTrack: 2,
|
|
113
|
+
start: 1,
|
|
114
|
+
duration: 2,
|
|
115
|
+
trackOrder,
|
|
116
|
+
excludeKey: null,
|
|
117
|
+
}),
|
|
118
|
+
).toEqual({ track: 2, needsInsert: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => {
|
|
122
|
+
// desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane
|
|
123
|
+
// here). Landing on it would overlap, so the empty-zone branch must insert —
|
|
124
|
+
// not silently land on the occupied track (the placement hole).
|
|
125
|
+
const els = [el("blocker", 5, 0, 5)];
|
|
126
|
+
expect(
|
|
127
|
+
resolvePlacement({
|
|
128
|
+
elements: els,
|
|
129
|
+
desiredTrack: 5,
|
|
130
|
+
start: 1,
|
|
131
|
+
duration: 2,
|
|
132
|
+
trackOrder: [],
|
|
133
|
+
excludeKey: null,
|
|
134
|
+
}),
|
|
135
|
+
).toEqual({ track: 5, needsInsert: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => {
|
|
139
|
+
// desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has
|
|
140
|
+
// no lane here. The old code short-circuited on isLaneFree and landed on the
|
|
141
|
+
// foreign-zone lane; the zone check must win and signal an insert.
|
|
142
|
+
const els = [el("elsewhere", 9, 0, 5)];
|
|
143
|
+
expect(
|
|
144
|
+
resolvePlacement({
|
|
145
|
+
elements: els,
|
|
146
|
+
desiredTrack: 5,
|
|
147
|
+
start: 1,
|
|
148
|
+
duration: 2,
|
|
149
|
+
trackOrder: [],
|
|
150
|
+
excludeKey: null,
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({ track: 5, needsInsert: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => {
|
|
156
|
+
const els = [el("self", 1, 0, 5)];
|
|
157
|
+
expect(
|
|
158
|
+
resolvePlacement({
|
|
159
|
+
elements: els,
|
|
160
|
+
desiredTrack: 1,
|
|
161
|
+
start: 0,
|
|
162
|
+
duration: 5,
|
|
163
|
+
trackOrder,
|
|
164
|
+
excludeKey: "self",
|
|
165
|
+
}),
|
|
166
|
+
).toEqual({ track: 1, needsInsert: false });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("resolveInsertRow", () => {
|
|
171
|
+
const n = 3; // three lanes: rows 0,1,2
|
|
172
|
+
|
|
173
|
+
it("targets the lane (null) when over its middle band", () => {
|
|
174
|
+
expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("inserts at the top boundary of a lane when near its top edge", () => {
|
|
178
|
+
expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("inserts at the bottom boundary of a lane when near its bottom edge", () => {
|
|
182
|
+
expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("inserts above the top lane when the pointer is above everything", () => {
|
|
186
|
+
expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("inserts below the bottom lane when the pointer is past the last lane", () => {
|
|
190
|
+
expect(resolveInsertRow(3.4, n, 0.22)).toBe(3);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// (The production insert-band suite imports INSERT_BOUNDARY_BAND from timelineLayout,
|
|
195
|
+
// which lands with the timeline glue swap — the full suite re-lands there.)
|
|
196
|
+
describe("clampTrackToZone", () => {
|
|
197
|
+
// trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
|
|
198
|
+
const order = [0, 1, 2, 3];
|
|
199
|
+
|
|
200
|
+
it("is a no-op when there is no audio zone", () => {
|
|
201
|
+
expect(clampTrackToZone(3, order, -1, false)).toBe(3);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps a visual clip in the visual zone", () => {
|
|
205
|
+
expect(clampTrackToZone(1, order, 2, false)).toBe(1); // already visual
|
|
206
|
+
expect(clampTrackToZone(3, order, 2, false)).toBe(1); // in audio → last visual lane
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("keeps an audio clip in the audio zone", () => {
|
|
210
|
+
expect(clampTrackToZone(2, order, 2, true)).toBe(2); // already audio
|
|
211
|
+
expect(clampTrackToZone(0, order, 2, true)).toBe(2); // in visual → first audio lane
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("isInsertAllowedForZone", () => {
|
|
216
|
+
// audioRow = 2
|
|
217
|
+
it("allows any insert when there is no audio zone", () => {
|
|
218
|
+
expect(isInsertAllowedForZone(0, -1, false)).toBe(true);
|
|
219
|
+
expect(isInsertAllowedForZone(3, -1, true)).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("allows a visual insert only at/above the audio zone top", () => {
|
|
223
|
+
expect(isInsertAllowedForZone(0, 2, false)).toBe(true);
|
|
224
|
+
expect(isInsertAllowedForZone(2, 2, false)).toBe(true); // bottom of the visual zone
|
|
225
|
+
expect(isInsertAllowedForZone(3, 2, false)).toBe(false); // inside the audio zone
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("allows an audio insert only at/below the audio zone top (audio clips make audio tracks)", () => {
|
|
229
|
+
expect(isInsertAllowedForZone(2, 2, true)).toBe(true);
|
|
230
|
+
expect(isInsertAllowedForZone(4, 2, true)).toBe(true); // below the bottom
|
|
231
|
+
expect(isInsertAllowedForZone(1, 2, true)).toBe(false); // inside the visual zone
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => {
|
|
236
|
+
// order [0,1,2] visual + [3] audio. audioRow = 3.
|
|
237
|
+
const order = [0, 1, 2, 3];
|
|
238
|
+
const audioTracks = new Set([3]);
|
|
239
|
+
const base = {
|
|
240
|
+
order,
|
|
241
|
+
audioTracks,
|
|
242
|
+
deliberateInsertRow: null as number | null,
|
|
243
|
+
start: 2,
|
|
244
|
+
duration: 2,
|
|
245
|
+
dragKey: "x",
|
|
246
|
+
isAudio: false,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
it("lands on the aimed track when it is free at that time", () => {
|
|
250
|
+
expect(
|
|
251
|
+
resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }),
|
|
252
|
+
).toEqual({ track: 1, insertRow: null });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => {
|
|
256
|
+
expect(
|
|
257
|
+
resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
|
|
258
|
+
).toEqual({ track: 0, insertRow: null });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("relocates DOWN when the tracks above are also occupied", () => {
|
|
262
|
+
expect(
|
|
263
|
+
resolveZoneDropPlacement({
|
|
264
|
+
...base,
|
|
265
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)],
|
|
266
|
+
desiredTrack: 1,
|
|
267
|
+
}),
|
|
268
|
+
).toEqual({ track: 2, insertRow: null });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
|
|
272
|
+
expect(
|
|
273
|
+
resolveZoneDropPlacement({
|
|
274
|
+
...base,
|
|
275
|
+
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)],
|
|
276
|
+
desiredTrack: 1,
|
|
277
|
+
}),
|
|
278
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Every visual lane (0,1,2) occupied across the drop span → no free lane exists.
|
|
282
|
+
const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
|
|
283
|
+
|
|
284
|
+
it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => {
|
|
285
|
+
// The clip must NOT return to its origin; it gets a fresh track adjacent to the
|
|
286
|
+
// aim. Default bias = below the aimed row.
|
|
287
|
+
const result = resolveZoneDropPlacement({
|
|
288
|
+
...base,
|
|
289
|
+
elements: allVisualOccupied(),
|
|
290
|
+
desiredTrack: 1,
|
|
291
|
+
});
|
|
292
|
+
expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back
|
|
293
|
+
expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => {
|
|
297
|
+
expect(
|
|
298
|
+
resolveZoneDropPlacement({
|
|
299
|
+
...base,
|
|
300
|
+
elements: allVisualOccupied(),
|
|
301
|
+
desiredTrack: 1,
|
|
302
|
+
preferInsertAbove: true,
|
|
303
|
+
}),
|
|
304
|
+
).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1
|
|
305
|
+
// Same aim, pointer in the lower half → the track opens BELOW the aimed row.
|
|
306
|
+
expect(
|
|
307
|
+
resolveZoneDropPlacement({
|
|
308
|
+
...base,
|
|
309
|
+
elements: allVisualOccupied(),
|
|
310
|
+
desiredTrack: 1,
|
|
311
|
+
preferInsertAbove: false,
|
|
312
|
+
}),
|
|
313
|
+
).toEqual({ track: 1, insertRow: 2 });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("shares a track for sequential (non-overlapping) clips", () => {
|
|
317
|
+
expect(
|
|
318
|
+
resolveZoneDropPlacement({
|
|
319
|
+
...base,
|
|
320
|
+
elements: [el("a", 1, 0, 2)],
|
|
321
|
+
desiredTrack: 1,
|
|
322
|
+
start: 2,
|
|
323
|
+
}),
|
|
324
|
+
).toEqual({ track: 1, insertRow: null });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("clamps a visual clip OUT of the audio zone before placing", () => {
|
|
328
|
+
expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({
|
|
329
|
+
track: 2,
|
|
330
|
+
insertRow: null,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("clamps an audio clip INTO the audio zone before placing", () => {
|
|
335
|
+
expect(
|
|
336
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }),
|
|
337
|
+
).toEqual({ track: 3, insertRow: null });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => {
|
|
341
|
+
// 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the
|
|
342
|
+
// create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The
|
|
343
|
+
// old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4,
|
|
344
|
+
// creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}.
|
|
345
|
+
// It must now anchor to the top boundary of the visual zone.
|
|
346
|
+
expect(
|
|
347
|
+
resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }),
|
|
348
|
+
).toEqual({ track: -1, insertRow: 0 });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => {
|
|
352
|
+
// Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual
|
|
353
|
+
// clip must land at the bottom of the visual zone (row 3 = just above audio),
|
|
354
|
+
// not past the audio lanes.
|
|
355
|
+
expect(
|
|
356
|
+
resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }),
|
|
357
|
+
).toEqual({ track: 4, insertRow: 3 });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => {
|
|
361
|
+
// An audio clip aimed above its zone (sentinel below its own min lane) anchors
|
|
362
|
+
// to the audio zone's top boundary (row 3), not into the visual zone.
|
|
363
|
+
expect(
|
|
364
|
+
resolveZoneDropPlacement({
|
|
365
|
+
...base,
|
|
366
|
+
elements: [el("a", 3, 0, 10)],
|
|
367
|
+
desiredTrack: -1,
|
|
368
|
+
isAudio: true,
|
|
369
|
+
}),
|
|
370
|
+
).toEqual({ track: -1, insertRow: 3 });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("honors a deliberate boundary insert in the clip's own zone", () => {
|
|
374
|
+
expect(
|
|
375
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }),
|
|
376
|
+
).toEqual({ track: 1, insertRow: 1 });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => {
|
|
380
|
+
expect(
|
|
381
|
+
resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }),
|
|
382
|
+
).toEqual({ track: 1, insertRow: null });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("lets an AUDIO clip create a new audio track via a boundary insert", () => {
|
|
386
|
+
expect(
|
|
387
|
+
resolveZoneDropPlacement({
|
|
388
|
+
...base,
|
|
389
|
+
elements: [],
|
|
390
|
+
desiredTrack: 3,
|
|
391
|
+
isAudio: true,
|
|
392
|
+
deliberateInsertRow: 4,
|
|
393
|
+
}),
|
|
394
|
+
).toEqual({ track: 3, insertRow: 4 });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => {
|
|
398
|
+
// No audio zone exists yet (audioTracks empty). The audio clip is aimed at the
|
|
399
|
+
// sole visual lane, which is occupied at the drop time. The zone-drop must
|
|
400
|
+
// create the audio zone's first lane (insertRow set) rather than stacking the
|
|
401
|
+
// audio clip onto the occupied visual track — the no-overlap invariant.
|
|
402
|
+
const result = resolveZoneDropPlacement({
|
|
403
|
+
order: [0],
|
|
404
|
+
audioTracks: new Set<number>(),
|
|
405
|
+
elements: [el("v", 0, 0, 5)],
|
|
406
|
+
desiredTrack: 0,
|
|
407
|
+
deliberateInsertRow: null,
|
|
408
|
+
start: 1,
|
|
409
|
+
duration: 2,
|
|
410
|
+
dragKey: "audio",
|
|
411
|
+
isAudio: true,
|
|
412
|
+
});
|
|
413
|
+
expect(result.insertRow).not.toBeNull();
|
|
414
|
+
expect(result).toEqual({ track: 0, insertRow: 1 });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => {
|
|
418
|
+
// Same visual-only timeline, but the audio clip is aimed at a stretch the sole
|
|
419
|
+
// visual lane is FREE at. The zone check must still fire an insert (create the
|
|
420
|
+
// audio zone) rather than short-circuiting on isLaneFree and dropping the audio
|
|
421
|
+
// clip onto the visual lane — the kind-zone hole where the free-lane fast path
|
|
422
|
+
// beat the wrong-zone check.
|
|
423
|
+
const result = resolveZoneDropPlacement({
|
|
424
|
+
order: [0],
|
|
425
|
+
audioTracks: new Set<number>(),
|
|
426
|
+
elements: [el("v", 0, 0, 5)],
|
|
427
|
+
desiredTrack: 0,
|
|
428
|
+
deliberateInsertRow: null,
|
|
429
|
+
start: 10, // past the visual clip's end → the visual lane is FREE here
|
|
430
|
+
duration: 2,
|
|
431
|
+
dragKey: "audio",
|
|
432
|
+
isAudio: true,
|
|
433
|
+
});
|
|
434
|
+
expect(result.insertRow).not.toBeNull();
|
|
435
|
+
expect(result).toEqual({ track: 0, insertRow: 1 });
|
|
436
|
+
});
|
|
437
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
|
5
|
+
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
|
|
6
|
+
* clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
|
|
7
|
+
* would then snap back). `audioRow` = index in `trackOrder` of the first audio
|
|
8
|
+
* lane, or -1 when there is no audio zone yet (then it's a no-op).
|
|
9
|
+
*/
|
|
10
|
+
export function clampTrackToZone(
|
|
11
|
+
targetTrack: number,
|
|
12
|
+
trackOrder: number[],
|
|
13
|
+
audioRow: number,
|
|
14
|
+
isAudio: boolean,
|
|
15
|
+
): number {
|
|
16
|
+
if (audioRow < 0) return targetTrack;
|
|
17
|
+
const row = trackOrder.indexOf(targetTrack);
|
|
18
|
+
if (row < 0) return targetTrack;
|
|
19
|
+
if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
|
|
20
|
+
return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
|
|
25
|
+
* given kind. Visual clips may only insert visual lanes (boundary at/above the top
|
|
26
|
+
* of the audio zone); audio clips may only insert audio lanes (boundary at/below
|
|
27
|
+
* it) — so audio clips CAN create a new audio track, and neither kind inserts into
|
|
28
|
+
* the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
|
|
29
|
+
*/
|
|
30
|
+
export function isInsertAllowedForZone(
|
|
31
|
+
insertRow: number,
|
|
32
|
+
audioRow: number,
|
|
33
|
+
isAudio: boolean,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (audioRow < 0) return true;
|
|
36
|
+
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The full drop-placement decision for a dragged clip — one pure, testable unit.
|
|
41
|
+
* Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
|
|
42
|
+
* a new track is created only when needed. Order of resolution:
|
|
43
|
+
* 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
|
|
44
|
+
* clip's own zone → create a new track there.
|
|
45
|
+
* 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
|
|
46
|
+
* if free at [start, start+duration), else the nearest FREE lane in the zone
|
|
47
|
+
* (prefer up), else auto-create a new track right below the aimed lane.
|
|
48
|
+
* `audioTracks` = the set of track indices that currently hold audio (so the fn
|
|
49
|
+
* needs no element-kind import). Returns the landing `track` and, when a new track
|
|
50
|
+
* should be created, the `insertRow` boundary (else null).
|
|
51
|
+
*
|
|
52
|
+
* `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
|
|
53
|
+
* track) toward the boundary ABOVE the aimed row instead of below it, so the new
|
|
54
|
+
* lane opens on whichever side of the aimed clip the pointer is nearer (the drag
|
|
55
|
+
* preview passes the pointer's sub-row half). A clip whose aimed span is occupied
|
|
56
|
+
* never snaps back to its origin — it relocates to a free lane, or (none free)
|
|
57
|
+
* gets a fresh track next to the aim. Default (below) preserves prior behaviour.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
|
|
61
|
+
* real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
|
|
62
|
+
* repro) or a beyond-the-bottom index a downward one does. Anchors the new track
|
|
63
|
+
* to a boundary of the clip's OWN kind-zone so a visual insert can never land
|
|
64
|
+
* past the audio zone (the old below = order.length fallback dropped it BELOW the
|
|
65
|
+
* audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
|
|
66
|
+
* boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
|
|
67
|
+
* audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
|
|
68
|
+
*/
|
|
69
|
+
function outOfRangeZoneInsertRow(
|
|
70
|
+
order: number[],
|
|
71
|
+
zoneTracks: number[],
|
|
72
|
+
audioRow: number,
|
|
73
|
+
desired: number,
|
|
74
|
+
): number {
|
|
75
|
+
// No lane of this kind yet: fall to the split (audioRow) or the very top.
|
|
76
|
+
// A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
|
|
77
|
+
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
|
|
78
|
+
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
|
|
79
|
+
// rows: above the zone's min lane → its top boundary, else its bottom.
|
|
80
|
+
const zoneTop = order.indexOf(zoneTracks[0]);
|
|
81
|
+
const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
|
|
82
|
+
return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveZoneDropPlacement(input: {
|
|
86
|
+
order: number[];
|
|
87
|
+
audioTracks: ReadonlySet<number>;
|
|
88
|
+
elements: TimelineElement[];
|
|
89
|
+
desiredTrack: number;
|
|
90
|
+
deliberateInsertRow: number | null;
|
|
91
|
+
start: number;
|
|
92
|
+
duration: number;
|
|
93
|
+
dragKey: string;
|
|
94
|
+
isAudio: boolean;
|
|
95
|
+
preferInsertAbove?: boolean;
|
|
96
|
+
}): { track: number; insertRow: number | null } {
|
|
97
|
+
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
|
98
|
+
const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
|
|
99
|
+
const audioRow = order.findIndex((t) => audioTracks.has(t));
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
deliberateInsertRow !== null &&
|
|
103
|
+
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
|
|
104
|
+
) {
|
|
105
|
+
return { track: desiredTrack, insertRow: deliberateInsertRow };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
|
109
|
+
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
|
110
|
+
const placement = resolvePlacement({
|
|
111
|
+
elements,
|
|
112
|
+
desiredTrack: desired,
|
|
113
|
+
start,
|
|
114
|
+
duration,
|
|
115
|
+
trackOrder: zoneTracks,
|
|
116
|
+
excludeKey: dragKey,
|
|
117
|
+
});
|
|
118
|
+
if (placement.needsInsert) {
|
|
119
|
+
const desiredRow = order.indexOf(desired);
|
|
120
|
+
if (desiredRow < 0) {
|
|
121
|
+
return {
|
|
122
|
+
track: desired,
|
|
123
|
+
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Prefer the gap NEAREST the pointer: insert above the aimed row when the
|
|
127
|
+
// pointer sits in its upper half AND that boundary is in the clip's own zone
|
|
128
|
+
// (else the visual/audio split would be crossed) — otherwise fall to below.
|
|
129
|
+
// `desired` is clamped into the zone, so both boundaries stay in-zone.
|
|
130
|
+
const insertRow =
|
|
131
|
+
preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
|
|
132
|
+
? desiredRow
|
|
133
|
+
: desiredRow + 1;
|
|
134
|
+
return { track: desired, insertRow };
|
|
135
|
+
}
|
|
136
|
+
return { track: placement.track, insertRow: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fallback half-width (fraction of a track height) of the insert band straddling
|
|
141
|
+
* a lane boundary — used only when the caller passes no explicit band. Production
|
|
142
|
+
* threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
|
|
143
|
+
* inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
|
|
144
|
+
* NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
|
|
145
|
+
* back toward the old 0.32 (which armed an insert across ~64% of every row — the
|
|
146
|
+
* misfire that turned a plain horizontal drag into a phantom track insert).
|
|
147
|
+
*/
|
|
148
|
+
const INSERT_BAND = 3 / 48;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
|
152
|
+
* `rowFloat` is the pointer's position in track-height units from the top of the
|
|
153
|
+
* first lane (0 = top of lane 0). Returns the boundary row to insert at
|
|
154
|
+
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
|
155
|
+
* pointer is over a lane's middle band (a normal move/target).
|
|
156
|
+
*/
|
|
157
|
+
export function resolveInsertRow(
|
|
158
|
+
rowFloat: number,
|
|
159
|
+
trackCount: number,
|
|
160
|
+
band: number = INSERT_BAND,
|
|
161
|
+
): number | null {
|
|
162
|
+
if (trackCount === 0) return 0;
|
|
163
|
+
if (rowFloat <= 0) return 0;
|
|
164
|
+
if (rowFloat >= trackCount) return trackCount;
|
|
165
|
+
const lane = Math.floor(rowFloat);
|
|
166
|
+
const frac = rowFloat - lane;
|
|
167
|
+
if (frac < band) return lane;
|
|
168
|
+
if (frac > 1 - band) return lane + 1;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
|
|
173
|
+
export function timeRangesOverlap(
|
|
174
|
+
aStart: number,
|
|
175
|
+
aEnd: number,
|
|
176
|
+
bStart: number,
|
|
177
|
+
bEnd: number,
|
|
178
|
+
): boolean {
|
|
179
|
+
return aStart < bEnd && bStart < aEnd;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when no clip on `track` overlaps [start, end) — excluding the clip
|
|
184
|
+
* identified by `excludeKey` (the one being dragged).
|
|
185
|
+
*/
|
|
186
|
+
export function isLaneFree(
|
|
187
|
+
elements: TimelineElement[],
|
|
188
|
+
track: number,
|
|
189
|
+
start: number,
|
|
190
|
+
end: number,
|
|
191
|
+
excludeKey: string | null,
|
|
192
|
+
): boolean {
|
|
193
|
+
return !elements.some(
|
|
194
|
+
(el) =>
|
|
195
|
+
(el.key ?? el.id) !== excludeKey &&
|
|
196
|
+
el.track === track &&
|
|
197
|
+
timeRangesOverlap(start, end, el.start, el.start + el.duration),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface PlacementInput {
|
|
202
|
+
elements: TimelineElement[];
|
|
203
|
+
desiredTrack: number;
|
|
204
|
+
start: number;
|
|
205
|
+
duration: number;
|
|
206
|
+
trackOrder: number[];
|
|
207
|
+
excludeKey: string | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface PlacementResult {
|
|
211
|
+
/** The lane the clip should land on. */
|
|
212
|
+
track: number;
|
|
213
|
+
/**
|
|
214
|
+
* True when no existing lane was free and the caller should insert a new
|
|
215
|
+
* track instead of landing on `track` (which is then the desired lane as a
|
|
216
|
+
* last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
|
|
217
|
+
*/
|
|
218
|
+
needsInsert: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve where a dragged clip should land, avoiding overlap. If the desired
|
|
223
|
+
* lane is free, keep it. Otherwise search the nearest free lane, **preferring
|
|
224
|
+
* up** (all lanes above, nearest first), then down. If none is free, signal an
|
|
225
|
+
* insert and fall back to the desired lane.
|
|
226
|
+
*/
|
|
227
|
+
export function resolvePlacement({
|
|
228
|
+
elements,
|
|
229
|
+
desiredTrack,
|
|
230
|
+
start,
|
|
231
|
+
duration,
|
|
232
|
+
trackOrder,
|
|
233
|
+
excludeKey,
|
|
234
|
+
}: PlacementInput): PlacementResult {
|
|
235
|
+
const end = start + duration;
|
|
236
|
+
const idx = trackOrder.indexOf(desiredTrack);
|
|
237
|
+
// desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
|
|
238
|
+
// yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
|
|
239
|
+
// BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
|
|
240
|
+
// lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
|
|
241
|
+
// "free" only because that lane belongs to the wrong zone. Landing there would
|
|
242
|
+
// put the clip in the wrong kind-zone, so signal an insert to create the zone's
|
|
243
|
+
// first lane instead — regardless of whether the aimed span is occupied (#2195).
|
|
244
|
+
if (idx === -1) return { track: desiredTrack, needsInsert: true };
|
|
245
|
+
|
|
246
|
+
if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
|
|
247
|
+
return { track: desiredTrack, needsInsert: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Prefer up: nearest lane above first, then the rest above.
|
|
251
|
+
for (let up = idx - 1; up >= 0; up--) {
|
|
252
|
+
if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
|
|
253
|
+
return { track: trackOrder[up], needsInsert: false };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Then down: nearest lane below first.
|
|
257
|
+
for (let down = idx + 1; down < trackOrder.length; down++) {
|
|
258
|
+
if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
|
|
259
|
+
return { track: trackOrder[down], needsInsert: false };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { track: desiredTrack, needsInsert: true };
|
|
263
|
+
}
|