@hyperframes/studio 0.8.90 → 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-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
- package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +18 -14
- package/dist/index.html +1 -1
- package/dist/index.js +9190 -8916
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +32 -66
- package/src/player/components/timelineClipDragCommit.ts +19 -37
- package/src/player/components/timelineClipDragPreview.test.ts +283 -24
- package/src/player/components/timelineClipDragPreview.ts +51 -83
- 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/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- 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 -23
- package/src/player/components/timelineMultiDragPreview.ts +26 -25
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +14 -6
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +29 -66
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
type DragPreviewContext,
|
|
8
8
|
} from "./timelineClipDragPreview";
|
|
9
9
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
|
-
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
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],
|
|
@@ -456,14 +538,191 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
456
538
|
const child: TimelineElement = {
|
|
457
539
|
...clip("child", 2, childStart, 4, 5),
|
|
458
540
|
expandedHostKey: "host",
|
|
459
|
-
expandedParentStart: hostStart,
|
|
460
541
|
};
|
|
461
542
|
const elements = [host, child];
|
|
462
543
|
const keys = new Set(["host", "child"]);
|
|
463
544
|
const ghost = preview(child, elements, 2, 0.5, keys);
|
|
464
545
|
expect(ghost.previewTrack).toBe(0);
|
|
465
|
-
expect(ghost.previewStart).toBe(childStart
|
|
466
|
-
expect(committedStart(ghost, host, elements, keys)).toBe(
|
|
546
|
+
expect(ghost.previewStart).toBe(childStart);
|
|
547
|
+
expect(committedStart(ghost, host, elements, keys)).toBe(hostStart);
|
|
467
548
|
}
|
|
468
549
|
});
|
|
469
550
|
});
|
|
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
|
+
|
|
661
|
+
describe("a nested clip's drop stops at its host's start in the preview", () => {
|
|
662
|
+
// Host at 2 s: the clip may not start before 2 unless it was authored there.
|
|
663
|
+
const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
|
|
664
|
+
const saved = async (element: TimelineElement, start: number) => {
|
|
665
|
+
const updateElement = vi.fn();
|
|
666
|
+
await persistMoveEdits([{ element, updates: { start, track: element.track } }], {
|
|
667
|
+
elements: [element],
|
|
668
|
+
trackOrder: [0, 1, 2],
|
|
669
|
+
updateElement,
|
|
670
|
+
onMoveElements: vi.fn(async () => {}),
|
|
671
|
+
});
|
|
672
|
+
return updateElement.mock.calls[0]?.[1]?.start;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
it("holds a drop that snapping would put before the host, and saves the same start", async () => {
|
|
676
|
+
const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
|
|
677
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
678
|
+
...ctx(undefined, [logo]),
|
|
679
|
+
buildSnapTargets: () => [{ time: 1.9, type: "beat" }],
|
|
680
|
+
});
|
|
681
|
+
expect(next.previewStart).toBe(2);
|
|
682
|
+
expect(next.snapTime).toBeNull();
|
|
683
|
+
expect(await saved(logo, next.previewStart)).toBe(2);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("snaps from the floor, so an edge just past the host's start still catches", () => {
|
|
687
|
+
const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
|
|
688
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
689
|
+
...ctx(undefined, [logo]),
|
|
690
|
+
buildSnapTargets: () => [{ time: 2.1, type: "beat" }],
|
|
691
|
+
});
|
|
692
|
+
expect(next).toMatchObject({ previewStart: 2.1, snapTime: 2.1 });
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("keeps a mixed group's spacing when the nested member meets its floor", () => {
|
|
696
|
+
// Grab the top-level member: only the group clamp knows the nested one's floor.
|
|
697
|
+
const outro = clip("outro", 1, 8, 2, 0, "div");
|
|
698
|
+
const { drag, clientX, clientY } = horizontalDrag(outro, 1.5, -7);
|
|
699
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
700
|
+
...ctx(undefined, [logo, outro]),
|
|
701
|
+
selectedKeys: new Set(["logo", "outro"]),
|
|
702
|
+
});
|
|
703
|
+
expect(next.previewStart).toBe(5);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("leaves a clip authored before its host's start in place when grabbed", async () => {
|
|
707
|
+
const early: TimelineElement = { ...logo, id: "early", key: "early", domId: "early", start: 1 };
|
|
708
|
+
const { drag, clientX, clientY } = horizontalDrag(early, 0.5, 0);
|
|
709
|
+
expect(computeDragPreview(drag, clientX, clientY, ctx(undefined, [early])).previewStart).toBe(
|
|
710
|
+
1,
|
|
711
|
+
);
|
|
712
|
+
const trim = computeResizePreview(
|
|
713
|
+
{
|
|
714
|
+
element: early,
|
|
715
|
+
edge: "start",
|
|
716
|
+
originClientX: 0,
|
|
717
|
+
previewStart: 1,
|
|
718
|
+
previewDuration: 5,
|
|
719
|
+
started: true,
|
|
720
|
+
pointerId: 0,
|
|
721
|
+
},
|
|
722
|
+
0,
|
|
723
|
+
{ scroll: fakeScroll(), pps: PPS, buildSnapTargets: () => [] },
|
|
724
|
+
);
|
|
725
|
+
expect(trim).toMatchObject({ previewStart: 1, previewDuration: 5 });
|
|
726
|
+
expect(await saved(early, 1)).toBe(1);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
@@ -5,12 +5,8 @@ import {
|
|
|
5
5
|
resolveTimelineMinDuration,
|
|
6
6
|
} from "./timelineGroupEditing";
|
|
7
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
getTimelineRowFromY,
|
|
11
|
-
getTimelineRowHeight,
|
|
12
|
-
getTimelineRowPositionFromY,
|
|
13
|
-
} from "./timelineLayout";
|
|
8
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
9
|
+
import { getTimelineRowFromY } from "./timelineLayout";
|
|
14
10
|
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
15
11
|
import {
|
|
16
12
|
TIMELINE_SNAP_PX,
|
|
@@ -24,9 +20,8 @@ import {
|
|
|
24
20
|
applyTimelineGroupResizePreview,
|
|
25
21
|
type TimelineGroupResizeSession,
|
|
26
22
|
} from "./timelineGroupEditing";
|
|
27
|
-
import {
|
|
23
|
+
import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
28
24
|
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
29
|
-
import { resolveDragLandingStart } from "./timelineDragLanding";
|
|
30
25
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
31
26
|
|
|
32
27
|
/** Snap-target builder closure supplied by the hook (closes over refs + store). */
|
|
@@ -77,74 +72,61 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
|
|
|
77
72
|
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
|
|
78
73
|
}
|
|
79
74
|
|
|
80
|
-
/**
|
|
81
|
-
* Rigid group move: when the grabbed clip is part of a multi-selection, the
|
|
82
|
-
* WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
|
|
83
|
-
* Clamp that delta here — against every selected member's start — so the
|
|
84
|
-
* grabbed clip can't out-run the group: it STOPS the instant any member would
|
|
85
|
-
* cross 0, exactly as it lands on commit. Lane changes still apply to the
|
|
86
|
-
* grabbed clip only, so only the start (x) is constrained.
|
|
87
|
-
*/
|
|
88
|
-
function resolveGroupClampedStart(
|
|
89
|
-
snapStart: number,
|
|
90
|
-
element: TimelineElement,
|
|
91
|
-
dragKey: string,
|
|
92
|
-
elements: TimelineElement[],
|
|
93
|
-
selectedKeys: ReadonlySet<string>,
|
|
94
|
-
): number {
|
|
95
|
-
if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart;
|
|
96
|
-
const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start);
|
|
97
|
-
const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts);
|
|
98
|
-
return element.start + clampedDelta;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* The whole drop decision (no same-track overlap, zone-respecting, relocate or
|
|
103
|
-
* create) — one tested pure function, so what runs here is what's verified.
|
|
104
|
-
*/
|
|
75
|
+
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
|
|
105
76
|
function resolveDropPlacement(
|
|
106
77
|
drag: DraggedClipState,
|
|
107
78
|
clientY: number,
|
|
108
79
|
previewStart: number,
|
|
109
80
|
desiredTrack: number,
|
|
110
81
|
ctx: DragPreviewContext,
|
|
111
|
-
|
|
82
|
+
group: GroupDrag,
|
|
83
|
+
): { track: number; insertRow: number | null; start: number } {
|
|
112
84
|
const { scroll, trackOrder, rowHeights, elements } = ctx;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
|
116
|
-
const rowPosition = scroll
|
|
117
|
-
? getTimelineRowPositionFromY(
|
|
85
|
+
const rowFloat = scroll
|
|
86
|
+
? getTimelineRowFromY(
|
|
118
87
|
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
|
|
119
88
|
rowHeights,
|
|
120
89
|
)
|
|
121
|
-
:
|
|
122
|
-
|
|
123
|
-
// an insert only arms in the visible gutter between clip bodies.
|
|
124
|
-
const rawInsertRow = resolveInsertRow(
|
|
125
|
-
rowPosition.rowFloat,
|
|
126
|
-
trackOrder.length,
|
|
127
|
-
getTimelineInsertBoundaryBand(rowPosition.rowHeight),
|
|
128
|
-
);
|
|
129
|
-
// Pointer sub-row half: when a drop must auto-create a track (aimed span
|
|
130
|
-
// occupied, no free lane), open it on the side the pointer is nearer.
|
|
131
|
-
const preferInsertAbove = rowPosition.fraction < 0.5;
|
|
90
|
+
: 0;
|
|
91
|
+
const dragKey = drag.element.key ?? drag.element.id;
|
|
132
92
|
const audioTracks =
|
|
133
93
|
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
|
134
94
|
return resolveZoneDropPlacement({
|
|
135
95
|
order: trackOrder,
|
|
136
96
|
audioTracks,
|
|
137
|
-
elements,
|
|
97
|
+
elements: group.obstacles,
|
|
138
98
|
desiredTrack,
|
|
139
|
-
deliberateInsertRow:
|
|
99
|
+
deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
|
|
140
100
|
start: previewStart,
|
|
141
101
|
duration: drag.element.duration,
|
|
142
|
-
dragKey
|
|
102
|
+
dragKey,
|
|
143
103
|
isAudio: isAudioTimelineElement(drag.element),
|
|
144
|
-
|
|
104
|
+
minStart: group.floor,
|
|
105
|
+
origin: { track: drag.element.track, start: drag.element.start },
|
|
145
106
|
});
|
|
146
107
|
}
|
|
147
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
|
+
|
|
148
130
|
/** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
|
|
149
131
|
export function computeDragPreview(
|
|
150
132
|
drag: DraggedClipState,
|
|
@@ -152,7 +134,7 @@ export function computeDragPreview(
|
|
|
152
134
|
clientY: number,
|
|
153
135
|
ctx: DragPreviewContext,
|
|
154
136
|
): DraggedClipState {
|
|
155
|
-
const { scroll, pps, duration, trackOrder,
|
|
137
|
+
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
|
|
156
138
|
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
|
157
139
|
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
|
|
158
140
|
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
|
|
@@ -173,6 +155,7 @@ export function computeDragPreview(
|
|
|
173
155
|
originScrollLeft: drag.originScrollLeft,
|
|
174
156
|
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
|
175
157
|
pixelsPerSecond: pps,
|
|
158
|
+
minStart: clampToHostStart(drag.element, 0),
|
|
176
159
|
maxStart: dragMaxStart,
|
|
177
160
|
trackOrder,
|
|
178
161
|
},
|
|
@@ -194,40 +177,24 @@ export function computeDragPreview(
|
|
|
194
177
|
// rendered extent (see dragMaxStart) — the composition grows on commit.
|
|
195
178
|
dragMaxStart + drag.element.duration,
|
|
196
179
|
);
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
elements,
|
|
203
|
-
selectedKeys,
|
|
204
|
-
);
|
|
205
|
-
const { track: previewTrack, insertRow } = resolveDropPlacement(
|
|
206
|
-
drag,
|
|
207
|
-
clientY,
|
|
208
|
-
previewStart,
|
|
209
|
-
nextMove.track,
|
|
210
|
-
ctx,
|
|
211
|
-
);
|
|
212
|
-
const placed = { ...drag, previewStart, previewTrack, insertRow };
|
|
213
|
-
const snappedStart = resolveDragLandingStart(placed, {
|
|
214
|
-
elements,
|
|
215
|
-
trackOrder,
|
|
216
|
-
selectedKeys,
|
|
217
|
-
});
|
|
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;
|
|
218
185
|
return {
|
|
219
186
|
...drag,
|
|
220
187
|
started: true,
|
|
221
188
|
pointerClientX: clientX,
|
|
222
189
|
pointerClientY: clientY,
|
|
223
|
-
previewStart:
|
|
190
|
+
previewStart: placement.start,
|
|
224
191
|
previewTrack,
|
|
225
|
-
// The lane the POINTER aims at (
|
|
226
|
-
// 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.
|
|
227
194
|
desiredTrack: nextMove.track,
|
|
228
195
|
insertRow,
|
|
229
|
-
snapTime: snap.snapTime,
|
|
230
|
-
snapType: snap.snapType,
|
|
196
|
+
snapTime: placement.start === snap.start ? snap.snapTime : null,
|
|
197
|
+
snapType: placement.start === snap.start ? snap.snapType : null,
|
|
231
198
|
};
|
|
232
199
|
}
|
|
233
200
|
|
|
@@ -293,7 +260,7 @@ export function computeResizePreview(
|
|
|
293
260
|
duration: resize.element.duration,
|
|
294
261
|
originClientX: resize.originClientX,
|
|
295
262
|
pixelsPerSecond: pps,
|
|
296
|
-
minStart: 0,
|
|
263
|
+
minStart: clampToHostStart(resize.element, 0),
|
|
297
264
|
maxEnd,
|
|
298
265
|
playbackStart:
|
|
299
266
|
resize.edge === "start" && canSeedPlaybackStart
|
|
@@ -336,7 +303,8 @@ export function computeResizePreview(
|
|
|
336
303
|
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
|
|
337
304
|
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
338
305
|
const delta = snapped - nextResize.start;
|
|
339
|
-
const
|
|
306
|
+
const floor = clampToHostStart(resize.element, 0);
|
|
307
|
+
const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
|
|
340
308
|
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
341
309
|
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
|
|
342
310
|
snap = target;
|
|
@@ -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
|
/**
|