@hyperframes/studio 0.8.91 → 0.8.92
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/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-V-0rfhe6.js → index-C5_j5-3u.js} +153 -153
- package/dist/assets/{index-DSOLcE8X.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-DV-z19zB.js → index-DpzpqJ_j.js} +1 -1
- package/dist/index.d.ts +6 -11
- package/dist/index.html +1 -1
- package/dist/index.js +8770 -8791
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/timelineClipDragCommit.test.ts +14 -58
- package/src/player/components/timelineClipDragCommit.ts +14 -37
- package/src/player/components/timelineClipDragPreview.test.ts +213 -22
- package/src/player/components/timelineClipDragPreview.ts +46 -81
- package/src/player/components/timelineClipDragTypes.ts +3 -8
- package/src/player/components/timelineCollision.test.ts +100 -302
- package/src/player/components/timelineCollision.ts +73 -160
- package/src/player/components/timelineDragDrop.test.tsx +19 -5
- package/src/player/components/timelineDragDrop.ts +7 -9
- package/src/player/components/timelineDragLanding.ts +0 -49
- package/src/player/components/timelineLayout.test.ts +0 -25
- package/src/player/components/timelineLayout.ts +0 -16
- package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
- package/src/player/components/timelineMultiDragPreview.ts +25 -27
- package/src/player/components/timelineProviderStateBuilders.ts +7 -4
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelineProviderState.tsx +29 -67
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
10
|
import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
|
|
11
11
|
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
|
12
|
+
import { isMultiDragPassenger } from "./timelineMultiDragPreview";
|
|
13
|
+
import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
|
|
12
14
|
|
|
13
15
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
16
|
// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
|
|
@@ -168,7 +170,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
168
170
|
expect(next.previewTrack).toBe(0);
|
|
169
171
|
});
|
|
170
172
|
|
|
171
|
-
it("
|
|
173
|
+
it("an occupied aim in an expanded row stays on that row at the nearest free time", () => {
|
|
172
174
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H];
|
|
173
175
|
const dragged = clip("dragged", 0, 0, 1, 3);
|
|
174
176
|
const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
|
|
@@ -195,11 +197,13 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|
|
195
197
|
...ctx(rowHeights, occupied),
|
|
196
198
|
trackOrder: [0, 1],
|
|
197
199
|
});
|
|
198
|
-
expect(next.insertRow).
|
|
200
|
+
expect(next.insertRow).toBeNull();
|
|
201
|
+
expect(next.previewTrack).toBe(0);
|
|
202
|
+
expect(next.previewStart).toBe(1);
|
|
199
203
|
});
|
|
200
204
|
});
|
|
201
205
|
|
|
202
|
-
describe("computeDragPreview —
|
|
206
|
+
describe("computeDragPreview — a clip landing on an empty main track keeps its released start", () => {
|
|
203
207
|
// v-lower sits alone on lane 1; lane 0 (the main track) is empty.
|
|
204
208
|
const vLower = clip("v-lower", 1, 10, 4, 5);
|
|
205
209
|
|
|
@@ -236,11 +240,11 @@ describe("computeDragPreview — magnetic first clip on an empty main track", ()
|
|
|
236
240
|
});
|
|
237
241
|
}
|
|
238
242
|
|
|
239
|
-
it("dragging straight up onto the empty main track
|
|
243
|
+
it("dragging straight up onto the empty main track keeps the start", () => {
|
|
240
244
|
const next = dragUpToMainTrack([vLower]);
|
|
241
245
|
expect(next.previewTrack).toBe(0);
|
|
242
246
|
expect(next.insertRow).toBeNull();
|
|
243
|
-
expect(next.previewStart).toBe(
|
|
247
|
+
expect(next.previewStart).toBe(10);
|
|
244
248
|
});
|
|
245
249
|
|
|
246
250
|
it("does not touch the start once the main track already holds a clip", () => {
|
|
@@ -249,13 +253,13 @@ describe("computeDragPreview — magnetic first clip on an empty main track", ()
|
|
|
249
253
|
expect(next.previewStart).toBe(10); // unchanged — main track wasn't empty
|
|
250
254
|
});
|
|
251
255
|
|
|
252
|
-
it("aiming the
|
|
256
|
+
it("aiming the space above an empty main track opens a track there and keeps the start", () => {
|
|
253
257
|
const next = dragUpToMainTrack([vLower], -0.6);
|
|
254
258
|
expect(next.insertRow).toBe(0);
|
|
255
|
-
expect(next.previewStart).toBe(
|
|
259
|
+
expect(next.previewStart).toBe(10);
|
|
256
260
|
});
|
|
257
261
|
|
|
258
|
-
it("does not retime
|
|
262
|
+
it("does not retime a multi-selection whose grabbed clip lands on the empty main track", () => {
|
|
259
263
|
const vOther = clip("v-other", 1, 15, 3, 5);
|
|
260
264
|
const next = dragUpToMainTrack([vLower, vOther], 0.5, new Set(["v-lower", "v-other"]));
|
|
261
265
|
expect(next.previewTrack).toBe(0);
|
|
@@ -425,29 +429,107 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
425
429
|
|
|
426
430
|
it("plain move onto the empty main track", () => {
|
|
427
431
|
const ghost = preview(lower, [lower], 1, 0.5);
|
|
428
|
-
expect(ghost.previewStart).toBe(
|
|
432
|
+
expect(ghost.previewStart).toBe(10);
|
|
429
433
|
expect(committedStart(ghost, lower, [lower])).toBe(ghost.previewStart);
|
|
430
434
|
});
|
|
431
435
|
|
|
432
|
-
it("top-gutter insert that pushes the old track-0 clip down
|
|
436
|
+
it("top-gutter insert that pushes the old track-0 clip down keeps the pointer start", () => {
|
|
433
437
|
const oldMain = clip("old-main", 0, 0, 3, 5);
|
|
434
438
|
const elements = [oldMain, lower];
|
|
435
439
|
const ghost = preview(lower, elements, 1, -0.6);
|
|
436
440
|
expect(ghost.insertRow).toBe(0);
|
|
437
|
-
expect(ghost.previewStart).toBe(0);
|
|
438
|
-
expect(committedStart(ghost, lower, elements)).toBe(0);
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it("top-gutter insert with a clip staying on track 0 keeps the pointer start", () => {
|
|
442
|
-
const stays = clip("stays", 0, 0, 3, 5);
|
|
443
|
-
const elements = [stays, lower];
|
|
444
|
-
// Insert between lane 0 and 1: the resident track-0 clip does not move.
|
|
445
|
-
const ghost = preview(lower, elements, 1, 1.0);
|
|
446
441
|
expect(ghost.previewStart).toBe(10);
|
|
447
442
|
expect(committedStart(ghost, lower, elements)).toBe(10);
|
|
448
443
|
});
|
|
449
444
|
|
|
450
|
-
it("
|
|
445
|
+
it("a move onto an occupied row lands at its nearest free time, ghost and commit alike", () => {
|
|
446
|
+
const blocker = clip("blocker", 0, 8, 4, 5);
|
|
447
|
+
const elements = [blocker, lower];
|
|
448
|
+
const ghost = preview(lower, elements, 1, 0.5);
|
|
449
|
+
expect(ghost.insertRow).toBeNull();
|
|
450
|
+
expect(ghost.previewTrack).toBe(0);
|
|
451
|
+
expect(ghost.previewStart).toBe(12);
|
|
452
|
+
expect(committedStart(ghost, lower, elements)).toBe(12);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("a drop onto an occupied row stays on that row", () => {
|
|
456
|
+
const title = clip("title", 0, 0, 10, 3, "text");
|
|
457
|
+
const subtitle = clip("subtitle", 1, 0, 6, 2, "text");
|
|
458
|
+
const tag = clip("tag", 2, 2, 5, 1, "text");
|
|
459
|
+
const rows = [title, subtitle, tag];
|
|
460
|
+
|
|
461
|
+
it("Tag dropped on the Subtitle row lands right after Subtitle, with no new track", () => {
|
|
462
|
+
const ghost = preview(tag, rows, 2, 1.5);
|
|
463
|
+
expect(ghost).toMatchObject({ insertRow: null, previewTrack: 1, previewStart: 6 });
|
|
464
|
+
expect(committedStart(ghost, tag, rows)).toBe(6);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("Tag dropped on the top Title row stays on track 0 after Title, not snapped to 0", () => {
|
|
468
|
+
const ghost = preview(tag, rows, 2, 0.5);
|
|
469
|
+
expect(ghost).toMatchObject({ insertRow: null, previewTrack: 0, previewStart: 10 });
|
|
470
|
+
expect(committedStart(ghost, tag, rows)).toBe(10);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("the edge of a row is still that row, not a new track", () => {
|
|
474
|
+
for (const edge of [0.02, 0.98, 1.02]) {
|
|
475
|
+
const ghost = preview(tag, rows, 2, edge);
|
|
476
|
+
expect(ghost.insertRow).toBeNull();
|
|
477
|
+
expect(ghost.previewTrack).toBe(Math.floor(edge));
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("a clip released on or near its own spot stays there", () => {
|
|
483
|
+
// Frame-aligned edges with three decimals: no centisecond start fits m's own slot.
|
|
484
|
+
const a = clip("a", 0, 0, 3.333, 1);
|
|
485
|
+
const m = clip("m", 0, 3.333, 3.333, 1);
|
|
486
|
+
const b = clip("b", 0, 6.666, 4, 1);
|
|
487
|
+
const row = [a, m, b];
|
|
488
|
+
|
|
489
|
+
it("keeps its start and writes nothing when released where it started", () => {
|
|
490
|
+
const ghost = preview(m, row, 0, 0.5);
|
|
491
|
+
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 3.333 });
|
|
492
|
+
expect(committedStart(ghost, m, row)).toBeUndefined();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("goes back to its own spot from a small nudge, snapped or not", () => {
|
|
496
|
+
const snapped = [{ time: 3.333, type: "clip-edge" as const }];
|
|
497
|
+
for (const targets of [[], snapped]) {
|
|
498
|
+
const { drag, clientX, clientY } = horizontalDrag(m, 0.5, 0.1);
|
|
499
|
+
const ghost = computeDragPreview(drag, clientX, clientY, {
|
|
500
|
+
...ctx(undefined, row),
|
|
501
|
+
buildSnapTargets: () => targets,
|
|
502
|
+
});
|
|
503
|
+
expect(ghost).toMatchObject({ previewTrack: 0, previewStart: 3.333 });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("counts its own start only on its own row, where keeping it rewrites nothing", () => {
|
|
508
|
+
// Row 1 has the same off-grid slot, but a move there writes a rounded start that would overlap.
|
|
509
|
+
const c = clip("c", 1, 0, 3.333, 1);
|
|
510
|
+
const d = clip("d", 1, 6.666, 4, 1);
|
|
511
|
+
const ghost = preview(m, [...row, c, d], 0, 1.5);
|
|
512
|
+
expect(ghost).toMatchObject({ previewTrack: 1, insertRow: null, previewStart: 10.67 });
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("drops the snap guide when the row moves the clip off the snapped time", () => {
|
|
517
|
+
const a = clip("a", 0, 0, 6, 1);
|
|
518
|
+
const b = clip("b", 1, 3, 2, 1);
|
|
519
|
+
const { drag, clientX } = horizontalDrag(b, 1.5, 0);
|
|
520
|
+
const ghost = computeDragPreview(drag, clientX, yForRow(0.5), {
|
|
521
|
+
...ctx(undefined, [a, b]),
|
|
522
|
+
buildSnapTargets: () => [{ time: 3, type: "beat" }],
|
|
523
|
+
});
|
|
524
|
+
expect(ghost).toMatchObject({
|
|
525
|
+
previewTrack: 0,
|
|
526
|
+
previewStart: 6,
|
|
527
|
+
snapTime: null,
|
|
528
|
+
snapType: null,
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("expanded child dragged with its host: the host keeps its start and the ghost matches", () => {
|
|
451
533
|
for (const [hostStart, childStart] of [
|
|
452
534
|
[30, 32],
|
|
453
535
|
[20, 22],
|
|
@@ -461,12 +543,121 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
461
543
|
const keys = new Set(["host", "child"]);
|
|
462
544
|
const ghost = preview(child, elements, 2, 0.5, keys);
|
|
463
545
|
expect(ghost.previewTrack).toBe(0);
|
|
464
|
-
expect(ghost.previewStart).toBe(childStart
|
|
465
|
-
expect(committedStart(ghost, host, elements, keys)).toBe(
|
|
546
|
+
expect(ghost.previewStart).toBe(childStart);
|
|
547
|
+
expect(committedStart(ghost, host, elements, keys)).toBe(hostStart);
|
|
466
548
|
}
|
|
467
549
|
});
|
|
468
550
|
});
|
|
469
551
|
|
|
552
|
+
describe("computeDragPreview — a group move keeps its shape", () => {
|
|
553
|
+
it("never bumps the grabbed clip further left than the group's 0 limit", () => {
|
|
554
|
+
const a = clip("a", 1, 0.5, 1, 1);
|
|
555
|
+
const b = clip("b", 2, 7, 2, 1);
|
|
556
|
+
const c = clip("c", 0, 6.4, 3.6, 1);
|
|
557
|
+
const elements = [c, a, b];
|
|
558
|
+
const selectedKeys = new Set(["a", "b"]);
|
|
559
|
+
const { drag } = horizontalDrag(b, 2.5, 0);
|
|
560
|
+
// Up two rows and 0.5 s left: the group limit allows 6.5 s, which overlaps c.
|
|
561
|
+
const ghost = computeDragPreview(drag, 800 - 0.5 * PPS, yForRow(0.5), {
|
|
562
|
+
...ctx(undefined, elements),
|
|
563
|
+
selectedKeys,
|
|
564
|
+
});
|
|
565
|
+
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
|
|
566
|
+
const onMoveElements = vi.fn();
|
|
567
|
+
commitDraggedClipMove(ghost, {
|
|
568
|
+
elements,
|
|
569
|
+
trackOrder: [0, 1, 2],
|
|
570
|
+
updateElement: vi.fn(),
|
|
571
|
+
onMoveElement: vi.fn(),
|
|
572
|
+
onMoveElements,
|
|
573
|
+
selectedKeys,
|
|
574
|
+
});
|
|
575
|
+
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
|
576
|
+
element: TimelineElement;
|
|
577
|
+
updates: { start: number };
|
|
578
|
+
}>;
|
|
579
|
+
const moved = Object.fromEntries(
|
|
580
|
+
edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
|
|
581
|
+
);
|
|
582
|
+
expect(moved).toEqual({ a: 3, b: 3 });
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Drags `grabbed` by `seconds` onto `row` and commits; returns each written clip's start.
|
|
586
|
+
function groupMove(
|
|
587
|
+
grabbed: TimelineElement,
|
|
588
|
+
elements: TimelineElement[],
|
|
589
|
+
selectedKeys: ReadonlySet<string>,
|
|
590
|
+
row: number,
|
|
591
|
+
seconds: number,
|
|
592
|
+
) {
|
|
593
|
+
const { drag } = horizontalDrag(grabbed, grabbed.track + 0.5, 0);
|
|
594
|
+
const ghost = computeDragPreview(drag, 800 + seconds * PPS, yForRow(row + 0.5), {
|
|
595
|
+
...ctx(undefined, elements),
|
|
596
|
+
selectedKeys,
|
|
597
|
+
});
|
|
598
|
+
const onMoveElements = vi.fn();
|
|
599
|
+
commitDraggedClipMove(ghost, {
|
|
600
|
+
elements,
|
|
601
|
+
trackOrder: [0, 1, 2],
|
|
602
|
+
updateElement: vi.fn(),
|
|
603
|
+
onMoveElement: vi.fn(),
|
|
604
|
+
onMoveElements,
|
|
605
|
+
selectedKeys,
|
|
606
|
+
});
|
|
607
|
+
const edits = onMoveElements.mock.calls[0][0] as Array<{
|
|
608
|
+
element: TimelineElement;
|
|
609
|
+
updates: { start: number; track: number };
|
|
610
|
+
}>;
|
|
611
|
+
return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("lets a group whose leftmost clip starts off the centisecond grid reach 0 exactly", () => {
|
|
615
|
+
const a = clip("a", 1, 0.333, 1, 1);
|
|
616
|
+
const b = clip("b", 0, 7, 2, 1);
|
|
617
|
+
const written = groupMove(b, [a, b], new Set(["a", "b"]), 0, -20);
|
|
618
|
+
expect(written.a.start).toBe(0);
|
|
619
|
+
expect(written.b.start).toBeCloseTo(6.667, 6);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("does not treat the clips moving with it as obstacles", () => {
|
|
623
|
+
const a = clip("a", 0, 0, 2, 1);
|
|
624
|
+
const b = clip("b", 0, 3, 2, 1);
|
|
625
|
+
const { drag, clientX, clientY } = horizontalDrag(a, 0.5, 2);
|
|
626
|
+
const ghost = computeDragPreview(drag, clientX, clientY, {
|
|
627
|
+
...ctx(undefined, [a, b]),
|
|
628
|
+
selectedKeys: new Set(["a", "b"]),
|
|
629
|
+
});
|
|
630
|
+
// b moves 2 s too, so a may take 2 to 4 s; were b an obstacle, a would stop at 1 s.
|
|
631
|
+
expect(ghost).toMatchObject({ previewTrack: 0, previewStart: 2 });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("treats a locked clip swept into the selection as an obstacle, since it does not move", () => {
|
|
635
|
+
const locked: TimelineElement = { ...clip("locked", 0, 4, 2, 1), timelineLocked: true };
|
|
636
|
+
const b = clip("b", 1, 0, 1, 1);
|
|
637
|
+
const written = groupMove(b, [locked, b], new Set(["locked", "b"]), 0, 4.5);
|
|
638
|
+
// 4.5 s overlaps the locked 4-6 s clip; 3 s and 6 s are equally near, and a tie goes later.
|
|
639
|
+
expect(written).toEqual({ b: { start: 6, track: 0 } });
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("resolveMultiDragPreview — the live ghosts follow the clips that move", () => {
|
|
644
|
+
it("slides the movable selected clips but not a locked one swept into the selection", () => {
|
|
645
|
+
const locked: TimelineElement = { ...clip("locked", 0, 4, 2, 1), timelineLocked: true };
|
|
646
|
+
const b = clip("b", 1, 0, 1, 1);
|
|
647
|
+
const rider = clip("rider", 2, 8, 1, 1);
|
|
648
|
+
const elements = [locked, b, rider];
|
|
649
|
+
const selectedKeys = new Set(["locked", "b", "rider"]);
|
|
650
|
+
const { drag } = horizontalDrag(b, 1.5, 0);
|
|
651
|
+
const ghost = computeDragPreview(drag, 800 + 4.5 * PPS, yForRow(0.5), {
|
|
652
|
+
...ctx(undefined, elements),
|
|
653
|
+
selectedKeys,
|
|
654
|
+
});
|
|
655
|
+
const preview = resolveMultiDragPreview(ghost, selectedKeys, elements);
|
|
656
|
+
expect(preview && isMultiDragPassenger("rider", preview)).toBe(true);
|
|
657
|
+
expect(preview && isMultiDragPassenger("locked", preview)).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
470
661
|
describe("a nested clip's drop stops at its host's start in the preview", () => {
|
|
471
662
|
// Host at 2 s: the clip may not start before 2 unless it was authored there.
|
|
472
663
|
const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
|
|
@@ -6,12 +6,7 @@ import {
|
|
|
6
6
|
} from "./timelineGroupEditing";
|
|
7
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { clampToHostStart } from "../store/timelineElement";
|
|
9
|
-
import {
|
|
10
|
-
getTimelineInsertBoundaryBand,
|
|
11
|
-
getTimelineRowFromY,
|
|
12
|
-
getTimelineRowHeight,
|
|
13
|
-
getTimelineRowPositionFromY,
|
|
14
|
-
} from "./timelineLayout";
|
|
9
|
+
import { getTimelineRowFromY } from "./timelineLayout";
|
|
15
10
|
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
16
11
|
import {
|
|
17
12
|
TIMELINE_SNAP_PX,
|
|
@@ -25,9 +20,8 @@ import {
|
|
|
25
20
|
applyTimelineGroupResizePreview,
|
|
26
21
|
type TimelineGroupResizeSession,
|
|
27
22
|
} from "./timelineGroupEditing";
|
|
28
|
-
import {
|
|
23
|
+
import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
29
24
|
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
30
|
-
import { resolveDragLandingStart } from "./timelineDragLanding";
|
|
31
25
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
32
26
|
|
|
33
27
|
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
@@ -78,74 +72,61 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
|
|
|
78
72
|
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
/**
|
|
82
|
-
* Rigid group move: when the grabbed clip is part of a multi-selection, the
|
|
83
|
-
* WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
|
|
84
|
-
* Clamp that delta here, and a lone clip's as a group of one, so the grabbed
|
|
85
|
-
* clip STOPS the instant any member would cross its floor (clampToHostStart),
|
|
86
|
-
* exactly as it lands on commit. Lane changes still apply to the grabbed clip
|
|
87
|
-
* only, so only the start (x) is constrained.
|
|
88
|
-
*/
|
|
89
|
-
function resolveGroupClampedStart(
|
|
90
|
-
snapStart: number,
|
|
91
|
-
element: TimelineElement,
|
|
92
|
-
dragKey: string,
|
|
93
|
-
elements: TimelineElement[],
|
|
94
|
-
selectedKeys: ReadonlySet<string>,
|
|
95
|
-
): number {
|
|
96
|
-
const inGroup = selectedKeys.size > 1 && selectedKeys.has(dragKey);
|
|
97
|
-
const members = inGroup ? elements.filter((e) => selectedKeys.has(e.key ?? e.id)) : [element];
|
|
98
|
-
const floors = members.map((e) => ({ start: e.start, minStart: clampToHostStart(e, 0) }));
|
|
99
|
-
return element.start + clampGroupMoveDelta(snapStart - element.start, floors);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The whole drop decision (no same-track overlap, zone-respecting, relocate or
|
|
104
|
-
* create) — one tested pure function, so what runs here is what's verified.
|
|
105
|
-
*/
|
|
75
|
+
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
|
|
106
76
|
function resolveDropPlacement(
|
|
107
77
|
drag: DraggedClipState,
|
|
108
78
|
clientY: number,
|
|
109
79
|
previewStart: number,
|
|
110
80
|
desiredTrack: number,
|
|
111
81
|
ctx: DragPreviewContext,
|
|
112
|
-
|
|
82
|
+
group: GroupDrag,
|
|
83
|
+
): { track: number; insertRow: number | null; start: number } {
|
|
113
84
|
const { scroll, trackOrder, rowHeights, elements } = ctx;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
|
117
|
-
const rowPosition = scroll
|
|
118
|
-
? getTimelineRowPositionFromY(
|
|
85
|
+
const rowFloat = scroll
|
|
86
|
+
? getTimelineRowFromY(
|
|
119
87
|
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
|
|
120
88
|
rowHeights,
|
|
121
89
|
)
|
|
122
|
-
:
|
|
123
|
-
|
|
124
|
-
// an insert only arms in the visible gutter between clip bodies.
|
|
125
|
-
const rawInsertRow = resolveInsertRow(
|
|
126
|
-
rowPosition.rowFloat,
|
|
127
|
-
trackOrder.length,
|
|
128
|
-
getTimelineInsertBoundaryBand(rowPosition.rowHeight),
|
|
129
|
-
);
|
|
130
|
-
// Pointer sub-row half: when a drop must auto-create a track (aimed span
|
|
131
|
-
// occupied, no free lane), open it on the side the pointer is nearer.
|
|
132
|
-
const preferInsertAbove = rowPosition.fraction < 0.5;
|
|
90
|
+
: 0;
|
|
91
|
+
const dragKey = drag.element.key ?? drag.element.id;
|
|
133
92
|
const audioTracks =
|
|
134
93
|
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
|
135
94
|
return resolveZoneDropPlacement({
|
|
136
95
|
order: trackOrder,
|
|
137
96
|
audioTracks,
|
|
138
|
-
elements,
|
|
97
|
+
elements: group.obstacles,
|
|
139
98
|
desiredTrack,
|
|
140
|
-
deliberateInsertRow:
|
|
99
|
+
deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
|
|
141
100
|
start: previewStart,
|
|
142
101
|
duration: drag.element.duration,
|
|
143
|
-
dragKey
|
|
102
|
+
dragKey,
|
|
144
103
|
isAudio: isAudioTimelineElement(drag.element),
|
|
145
|
-
|
|
104
|
+
minStart: group.floor,
|
|
105
|
+
origin: { track: drag.element.track, start: drag.element.start },
|
|
146
106
|
});
|
|
147
107
|
}
|
|
148
108
|
|
|
109
|
+
interface GroupDrag {
|
|
110
|
+
/** Clips the grabbed clip may not overlap: everything but the clips that move with it. */
|
|
111
|
+
obstacles: TimelineElement[];
|
|
112
|
+
/** Lowest start the grabbed clip may take (see groupMoveFloor). */
|
|
113
|
+
floor: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): GroupDrag {
|
|
117
|
+
const movers = resolveGroupMovers(
|
|
118
|
+
ctx.elements,
|
|
119
|
+
ctx.selectedKeys,
|
|
120
|
+
drag.element.key ?? drag.element.id,
|
|
121
|
+
);
|
|
122
|
+
if (!movers) return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []) };
|
|
123
|
+
const moving = new Set(movers.map((e) => e.key ?? e.id));
|
|
124
|
+
return {
|
|
125
|
+
obstacles: ctx.elements.filter((e) => !moving.has(e.key ?? e.id)),
|
|
126
|
+
floor: groupMoveFloor(drag.element, movers),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
149
130
|
/** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
|
|
150
131
|
export function computeDragPreview(
|
|
151
132
|
drag: DraggedClipState,
|
|
@@ -153,7 +134,7 @@ export function computeDragPreview(
|
|
|
153
134
|
clientY: number,
|
|
154
135
|
ctx: DragPreviewContext,
|
|
155
136
|
): DraggedClipState {
|
|
156
|
-
const { scroll, pps, duration, trackOrder,
|
|
137
|
+
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
|
|
157
138
|
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
|
158
139
|
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
|
|
159
140
|
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
|
|
@@ -196,40 +177,24 @@ export function computeDragPreview(
|
|
|
196
177
|
// rendered extent (see dragMaxStart) — the composition grows on commit.
|
|
197
178
|
dragMaxStart + drag.element.duration,
|
|
198
179
|
);
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
elements,
|
|
205
|
-
selectedKeys,
|
|
206
|
-
);
|
|
207
|
-
const { track: previewTrack, insertRow } = resolveDropPlacement(
|
|
208
|
-
drag,
|
|
209
|
-
clientY,
|
|
210
|
-
previewStart,
|
|
211
|
-
nextMove.track,
|
|
212
|
-
ctx,
|
|
213
|
-
);
|
|
214
|
-
const placed = { ...drag, previewStart, previewTrack, insertRow };
|
|
215
|
-
const snappedStart = resolveDragLandingStart(placed, {
|
|
216
|
-
elements,
|
|
217
|
-
trackOrder,
|
|
218
|
-
selectedKeys,
|
|
219
|
-
});
|
|
180
|
+
// A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
|
|
181
|
+
const group = resolveGroupDrag(drag, ctx);
|
|
182
|
+
const previewStart = Math.max(snap.start, group.floor);
|
|
183
|
+
const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
|
|
184
|
+
const { track: previewTrack, insertRow } = placement;
|
|
220
185
|
return {
|
|
221
186
|
...drag,
|
|
222
187
|
started: true,
|
|
223
188
|
pointerClientX: clientX,
|
|
224
189
|
pointerClientY: clientY,
|
|
225
|
-
previewStart:
|
|
190
|
+
previewStart: placement.start,
|
|
226
191
|
previewTrack,
|
|
227
|
-
// The lane the POINTER aims at (
|
|
228
|
-
// deliberate vertical lane change from a horizontal drag
|
|
192
|
+
// The lane the POINTER aims at (before the zone clamp): the commit reads it to
|
|
193
|
+
// tell a deliberate vertical lane change from a horizontal drag.
|
|
229
194
|
desiredTrack: nextMove.track,
|
|
230
195
|
insertRow,
|
|
231
|
-
snapTime:
|
|
232
|
-
snapType:
|
|
196
|
+
snapTime: placement.start === snap.start ? snap.snapTime : null,
|
|
197
|
+
snapType: placement.start === snap.start ? snap.snapType : null,
|
|
233
198
|
};
|
|
234
199
|
}
|
|
235
200
|
|
|
@@ -18,14 +18,9 @@ export interface DraggedClipState {
|
|
|
18
18
|
previewStart: number;
|
|
19
19
|
previewTrack: number;
|
|
20
20
|
/**
|
|
21
|
-
* The lane the POINTER aims at
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* collision rules bump the dragged clip to a free lane. The commit reads this
|
|
25
|
-
* to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
|
|
26
|
-
* stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
|
|
27
|
-
* bumped sideways (never touches z). Optional: when absent the commit falls
|
|
28
|
-
* back to `previewTrack` (pre-existing behaviour).
|
|
21
|
+
* The lane the POINTER aims at, before the kind-zone clamp picks `previewTrack`.
|
|
22
|
+
* The commit reads it to tell a deliberate vertical lane change (stacking sync
|
|
23
|
+
* allowed) from a horizontal drag. Absent: the commit falls back to `previewTrack`.
|
|
29
24
|
*/
|
|
30
25
|
desiredTrack?: number;
|
|
31
26
|
/**
|