@hyperframes/studio 0.8.89 → 0.8.91
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-CVoS7kv8.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-D35-L_yn.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-DBub9qQY.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BM8PwYby.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1349 -1066
- 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/useInlineTextEditing.tsx +2 -4
- 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/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/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 +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- 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/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- 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/sceneSwap.ts +21 -0
- 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/gsapUndoRestore.test.ts +58 -0
- package/src/utils/gsapUndoRestore.ts +6 -1
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/studioPreviewHelpers.test.ts +29 -0
- package/src/utils/studioPreviewHelpers.ts +37 -80
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -15,7 +15,7 @@ export interface TimelineProps
|
|
|
15
15
|
extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
|
|
16
16
|
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
17
17
|
sessionEpoch?: number;
|
|
18
|
-
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
18
|
+
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
|
|
19
19
|
onDrillDown?: (element: TimelineElement) => void;
|
|
20
20
|
/** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
|
|
21
21
|
renderClipContent?: (
|
|
@@ -315,7 +315,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
315
315
|
const child = {
|
|
316
316
|
...el("scene.html#title", 0.25, 12, 2),
|
|
317
317
|
sourceFile: "scene.html",
|
|
318
|
-
expandedParentStart: 10,
|
|
319
318
|
expandedHostKey: "host",
|
|
320
319
|
};
|
|
321
320
|
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -338,7 +337,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
338
337
|
const child = {
|
|
339
338
|
...el("scene.html#title", 0.25, 12, 2),
|
|
340
339
|
sourceFile: "scene.html",
|
|
341
|
-
expandedParentStart: 10,
|
|
342
340
|
expandedHostKey: "host",
|
|
343
341
|
};
|
|
344
342
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -360,7 +358,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
360
358
|
const child = {
|
|
361
359
|
...el("scene.html#title", 0.25, 12, 2),
|
|
362
360
|
sourceFile: "scene.html",
|
|
363
|
-
expandedParentStart: 10,
|
|
364
361
|
expandedHostKey: "host",
|
|
365
362
|
};
|
|
366
363
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -382,7 +379,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
382
379
|
const child = {
|
|
383
380
|
...el("scene.html#title", 0.25, 12, 2),
|
|
384
381
|
sourceFile: "scene.html",
|
|
385
|
-
expandedParentStart: 10,
|
|
386
382
|
expandedHostKey: "host",
|
|
387
383
|
};
|
|
388
384
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -406,7 +402,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
406
402
|
const child = {
|
|
407
403
|
...el("scene.html#title", 0.25, 12, 2),
|
|
408
404
|
sourceFile: "scene.html",
|
|
409
|
-
expandedParentStart: 10,
|
|
410
405
|
expandedHostKey: "host",
|
|
411
406
|
};
|
|
412
407
|
const { onMoveElement, onMoveElements } = runClipMove(
|
|
@@ -1279,7 +1274,6 @@ describe("commitDraggedClipMove", () => {
|
|
|
1279
1274
|
const child: TimelineElement = {
|
|
1280
1275
|
...el("child", 2, childStart, 4),
|
|
1281
1276
|
expandedHostKey: "host",
|
|
1282
|
-
expandedParentStart: hostStart,
|
|
1283
1277
|
};
|
|
1284
1278
|
const { onMoveElements } = runClipMove(
|
|
1285
1279
|
drag(child, { previewStart: childStart, previewTrack: 0 }),
|
|
@@ -1399,12 +1393,10 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1399
1393
|
const child1 = {
|
|
1400
1394
|
...el("child-1", 0.25, 0, 5),
|
|
1401
1395
|
sourceFile: "scene.html",
|
|
1402
|
-
expandedParentStart: 0,
|
|
1403
1396
|
};
|
|
1404
1397
|
const child2 = {
|
|
1405
1398
|
...el("child-2", 0.5, 0, 5),
|
|
1406
1399
|
sourceFile: "scene.html",
|
|
1407
|
-
expandedParentStart: 0,
|
|
1408
1400
|
};
|
|
1409
1401
|
const b = { ...el("b", 1, 0, 5), sourceFile: "index.html" };
|
|
1410
1402
|
const t = { ...el("t", 2, 0, 5), sourceFile: "index.html" };
|
|
@@ -1516,6 +1508,24 @@ describe("commitZMirrorLaneMove", () => {
|
|
|
1516
1508
|
});
|
|
1517
1509
|
});
|
|
1518
1510
|
|
|
1511
|
+
describe("persistMoveEdits: a nested row dropped before its host", () => {
|
|
1512
|
+
it("lands at the host's start in the store and in the persist", async () => {
|
|
1513
|
+
const logo = { ...el("logo", 0, 5, 5, "div"), parentCompositionStart: 2 };
|
|
1514
|
+
const updateElement = vi.fn();
|
|
1515
|
+
const onMoveElements = vi.fn(async (_edits: TimelineMoveEdit[]) => {});
|
|
1516
|
+
await persistMoveEdits([{ element: logo, updates: { start: 1, track: 0 } }], {
|
|
1517
|
+
elements: [logo],
|
|
1518
|
+
trackOrder: [0],
|
|
1519
|
+
updateElement,
|
|
1520
|
+
onMoveElements,
|
|
1521
|
+
});
|
|
1522
|
+
expect(updateElement).toHaveBeenCalledWith("logo", { start: 2, track: 0 });
|
|
1523
|
+
expect(onMoveElements.mock.calls[0]?.[0]).toEqual([
|
|
1524
|
+
{ element: logo, updates: { start: 2, track: 0 } },
|
|
1525
|
+
]);
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1519
1529
|
describe("persistMoveEdits convergence", () => {
|
|
1520
1530
|
it("reasserts a saved lane after a stale runtime sync", async () => {
|
|
1521
1531
|
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
2
3
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
|
3
4
|
// Type-only: erased at runtime, so the timelineZMirror → timelineClipDragCommit
|
|
4
5
|
// value-import edge stays acyclic.
|
|
@@ -107,6 +108,10 @@ export function persistMoveEdits(
|
|
|
107
108
|
coalesceMs?: number,
|
|
108
109
|
): Promise<boolean> {
|
|
109
110
|
if (edits.length === 0) return Promise.resolve(true);
|
|
111
|
+
edits = edits.map((e) => {
|
|
112
|
+
const start = clampToHostStart(e.element, e.updates.start);
|
|
113
|
+
return start === e.updates.start ? e : { ...e, updates: { ...e.updates, start } };
|
|
114
|
+
});
|
|
110
115
|
const { updateElement, onMoveElement, onMoveElements } = deps;
|
|
111
116
|
if (!onMoveElements) {
|
|
112
117
|
console.warn(
|
|
@@ -7,7 +7,7 @@ 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
12
|
|
|
13
13
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -456,7 +456,6 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
456
456
|
const child: TimelineElement = {
|
|
457
457
|
...clip("child", 2, childStart, 4, 5),
|
|
458
458
|
expandedHostKey: "host",
|
|
459
|
-
expandedParentStart: hostStart,
|
|
460
459
|
};
|
|
461
460
|
const elements = [host, child];
|
|
462
461
|
const keys = new Set(["host", "child"]);
|
|
@@ -467,3 +466,72 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
|
|
|
467
466
|
}
|
|
468
467
|
});
|
|
469
468
|
});
|
|
469
|
+
|
|
470
|
+
describe("a nested clip's drop stops at its host's start in the preview", () => {
|
|
471
|
+
// Host at 2 s: the clip may not start before 2 unless it was authored there.
|
|
472
|
+
const logo: TimelineElement = { ...clip("logo", 0, 5, 5, 0, "div"), parentCompositionStart: 2 };
|
|
473
|
+
const saved = async (element: TimelineElement, start: number) => {
|
|
474
|
+
const updateElement = vi.fn();
|
|
475
|
+
await persistMoveEdits([{ element, updates: { start, track: element.track } }], {
|
|
476
|
+
elements: [element],
|
|
477
|
+
trackOrder: [0, 1, 2],
|
|
478
|
+
updateElement,
|
|
479
|
+
onMoveElements: vi.fn(async () => {}),
|
|
480
|
+
});
|
|
481
|
+
return updateElement.mock.calls[0]?.[1]?.start;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
it("holds a drop that snapping would put before the host, and saves the same start", async () => {
|
|
485
|
+
const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
|
|
486
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
487
|
+
...ctx(undefined, [logo]),
|
|
488
|
+
buildSnapTargets: () => [{ time: 1.9, type: "beat" }],
|
|
489
|
+
});
|
|
490
|
+
expect(next.previewStart).toBe(2);
|
|
491
|
+
expect(next.snapTime).toBeNull();
|
|
492
|
+
expect(await saved(logo, next.previewStart)).toBe(2);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("snaps from the floor, so an edge just past the host's start still catches", () => {
|
|
496
|
+
const { drag, clientX, clientY } = horizontalDrag(logo, 0.5, -3.9);
|
|
497
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
498
|
+
...ctx(undefined, [logo]),
|
|
499
|
+
buildSnapTargets: () => [{ time: 2.1, type: "beat" }],
|
|
500
|
+
});
|
|
501
|
+
expect(next).toMatchObject({ previewStart: 2.1, snapTime: 2.1 });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("keeps a mixed group's spacing when the nested member meets its floor", () => {
|
|
505
|
+
// Grab the top-level member: only the group clamp knows the nested one's floor.
|
|
506
|
+
const outro = clip("outro", 1, 8, 2, 0, "div");
|
|
507
|
+
const { drag, clientX, clientY } = horizontalDrag(outro, 1.5, -7);
|
|
508
|
+
const next = computeDragPreview(drag, clientX, clientY, {
|
|
509
|
+
...ctx(undefined, [logo, outro]),
|
|
510
|
+
selectedKeys: new Set(["logo", "outro"]),
|
|
511
|
+
});
|
|
512
|
+
expect(next.previewStart).toBe(5);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("leaves a clip authored before its host's start in place when grabbed", async () => {
|
|
516
|
+
const early: TimelineElement = { ...logo, id: "early", key: "early", domId: "early", start: 1 };
|
|
517
|
+
const { drag, clientX, clientY } = horizontalDrag(early, 0.5, 0);
|
|
518
|
+
expect(computeDragPreview(drag, clientX, clientY, ctx(undefined, [early])).previewStart).toBe(
|
|
519
|
+
1,
|
|
520
|
+
);
|
|
521
|
+
const trim = computeResizePreview(
|
|
522
|
+
{
|
|
523
|
+
element: early,
|
|
524
|
+
edge: "start",
|
|
525
|
+
originClientX: 0,
|
|
526
|
+
previewStart: 1,
|
|
527
|
+
previewDuration: 5,
|
|
528
|
+
started: true,
|
|
529
|
+
pointerId: 0,
|
|
530
|
+
},
|
|
531
|
+
0,
|
|
532
|
+
{ scroll: fakeScroll(), pps: PPS, buildSnapTargets: () => [] },
|
|
533
|
+
);
|
|
534
|
+
expect(trim).toMatchObject({ previewStart: 1, previewDuration: 5 });
|
|
535
|
+
expect(await saved(early, 1)).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resolveTimelineMinDuration,
|
|
6
6
|
} from "./timelineGroupEditing";
|
|
7
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
8
9
|
import {
|
|
9
10
|
getTimelineInsertBoundaryBand,
|
|
10
11
|
getTimelineRowFromY,
|
|
@@ -80,10 +81,10 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
|
|
|
80
81
|
/**
|
|
81
82
|
* Rigid group move: when the grabbed clip is part of a multi-selection, the
|
|
82
83
|
* WHOLE formation shifts by its delta on commit (see timelineClipDragCommit).
|
|
83
|
-
* Clamp that delta here
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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.
|
|
87
88
|
*/
|
|
88
89
|
function resolveGroupClampedStart(
|
|
89
90
|
snapStart: number,
|
|
@@ -92,10 +93,10 @@ function resolveGroupClampedStart(
|
|
|
92
93
|
elements: TimelineElement[],
|
|
93
94
|
selectedKeys: ReadonlySet<string>,
|
|
94
95
|
): number {
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
return element.start +
|
|
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);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/**
|
|
@@ -173,6 +174,7 @@ export function computeDragPreview(
|
|
|
173
174
|
originScrollLeft: drag.originScrollLeft,
|
|
174
175
|
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
|
175
176
|
pixelsPerSecond: pps,
|
|
177
|
+
minStart: clampToHostStart(drag.element, 0),
|
|
176
178
|
maxStart: dragMaxStart,
|
|
177
179
|
trackOrder,
|
|
178
180
|
},
|
|
@@ -226,8 +228,8 @@ export function computeDragPreview(
|
|
|
226
228
|
// deliberate vertical lane change from a horizontal drag merely bumped sideways.
|
|
227
229
|
desiredTrack: nextMove.track,
|
|
228
230
|
insertRow,
|
|
229
|
-
snapTime: snap.snapTime,
|
|
230
|
-
snapType: snap.snapType,
|
|
231
|
+
snapTime: previewStart === snap.start ? snap.snapTime : null,
|
|
232
|
+
snapType: previewStart === snap.start ? snap.snapType : null,
|
|
231
233
|
};
|
|
232
234
|
}
|
|
233
235
|
|
|
@@ -293,7 +295,7 @@ export function computeResizePreview(
|
|
|
293
295
|
duration: resize.element.duration,
|
|
294
296
|
originClientX: resize.originClientX,
|
|
295
297
|
pixelsPerSecond: pps,
|
|
296
|
-
minStart: 0,
|
|
298
|
+
minStart: clampToHostStart(resize.element, 0),
|
|
297
299
|
maxEnd,
|
|
298
300
|
playbackStart:
|
|
299
301
|
resize.edge === "start" && canSeedPlaybackStart
|
|
@@ -336,7 +338,8 @@ export function computeResizePreview(
|
|
|
336
338
|
const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
|
|
337
339
|
const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
|
|
338
340
|
const delta = snapped - nextResize.start;
|
|
339
|
-
const
|
|
341
|
+
const floor = clampToHostStart(resize.element, 0);
|
|
342
|
+
const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
|
|
340
343
|
if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
|
|
341
344
|
if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
|
|
342
345
|
snap = target;
|
|
@@ -51,6 +51,7 @@ export interface TimelineMoveInput {
|
|
|
51
51
|
originScrollLeft?: number;
|
|
52
52
|
currentScrollLeft?: number;
|
|
53
53
|
pixelsPerSecond: number;
|
|
54
|
+
minStart?: number;
|
|
54
55
|
maxStart: number;
|
|
55
56
|
trackOrder: number[];
|
|
56
57
|
layerOrder?: TimelineLayerId[];
|
|
@@ -127,7 +128,7 @@ export function resolveTimelineMove(
|
|
|
127
128
|
const deltaTrack = Math.round(trackDeltaRaw);
|
|
128
129
|
const nextStart = clamp(
|
|
129
130
|
roundToCentiseconds(input.start + deltaTime),
|
|
130
|
-
0,
|
|
131
|
+
input.minStart ?? 0,
|
|
131
132
|
Math.max(0, input.maxStart),
|
|
132
133
|
);
|
|
133
134
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
|
|
2
2
|
import { roundToCenti } from "../../utils/rounding";
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
4
5
|
import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
|
@@ -88,6 +89,7 @@ export interface TimelineGroupTimingMember {
|
|
|
88
89
|
duration: number;
|
|
89
90
|
playbackStart?: number;
|
|
90
91
|
playbackRate?: number;
|
|
92
|
+
minStart?: number;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
export type TimelineGroupResizeEdge = "start" | "end";
|
|
@@ -107,7 +109,7 @@ function clampTimelineGroupMoveDelta(
|
|
|
107
109
|
members: readonly TimelineGroupTimingMember[],
|
|
108
110
|
): number {
|
|
109
111
|
if (members.length === 0) return 0;
|
|
110
|
-
const minDelta = Math.max(...members.map((member) => -member.start));
|
|
112
|
+
const minDelta = Math.max(...members.map((member) => -(member.start - (member.minStart ?? 0))));
|
|
111
113
|
return roundTimelineTime(Math.max(rawDelta, minDelta));
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -139,7 +141,9 @@ export function clampTimelineGroupResizeDelta(
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
// Rigid group: the applied delta is bounded by the most-constrained member.
|
|
142
|
-
const bounds = members.map((member) =>
|
|
144
|
+
const bounds = members.map((member) =>
|
|
145
|
+
clipStartTrimDeltaBounds(member, member.minStart ?? 0, minDuration),
|
|
146
|
+
);
|
|
143
147
|
const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
|
|
144
148
|
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
|
|
145
149
|
const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
|
|
@@ -243,6 +247,7 @@ export function buildTimelineGroupResizeMembers(
|
|
|
243
247
|
? (element.playbackStart ?? 0)
|
|
244
248
|
: element.playbackStart,
|
|
245
249
|
playbackRate: element.playbackRate,
|
|
250
|
+
minStart: clampToHostStart(element, 0),
|
|
246
251
|
}));
|
|
247
252
|
}
|
|
248
253
|
|
|
@@ -17,6 +17,8 @@ const base = (over: Partial<MultiDragPreviewInput> = {}): MultiDragPreviewInput
|
|
|
17
17
|
...over,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const starts = (...values: number[]) => values.map((start) => ({ start }));
|
|
21
|
+
|
|
20
22
|
describe("isMultiDragActive", () => {
|
|
21
23
|
it("is active when a started drag's clip is part of a 2+ selection", () => {
|
|
22
24
|
expect(isMultiDragActive(base())).toBe(true);
|
|
@@ -94,34 +96,34 @@ describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)",
|
|
|
94
96
|
|
|
95
97
|
describe("clampGroupMoveDelta (rigid group move)", () => {
|
|
96
98
|
it("passes a rightward delta through unchanged (no right wall)", () => {
|
|
97
|
-
expect(clampGroupMoveDelta(3,
|
|
98
|
-
expect(clampGroupMoveDelta(1000,
|
|
99
|
+
expect(clampGroupMoveDelta(3, starts(2, 5, 9))).toBe(3);
|
|
100
|
+
expect(clampGroupMoveDelta(1000, starts(0, 4))).toBe(1000);
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
it("passes a leftward delta through when no member would cross 0", () => {
|
|
102
104
|
// Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
|
|
103
|
-
expect(clampGroupMoveDelta(-3,
|
|
105
|
+
expect(clampGroupMoveDelta(-3, starts(5, 8, 12))).toBe(-3);
|
|
104
106
|
});
|
|
105
107
|
|
|
106
108
|
it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
|
|
107
109
|
// Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
|
|
108
110
|
// A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
|
|
109
111
|
// formation instead of out-running it.
|
|
110
|
-
expect(clampGroupMoveDelta(-5,
|
|
112
|
+
expect(clampGroupMoveDelta(-5, starts(2, 6, 10))).toBe(-2);
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
|
|
114
116
|
// Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
|
|
115
|
-
expect(clampGroupMoveDelta(-8,
|
|
117
|
+
expect(clampGroupMoveDelta(-8, starts(10, 1, 4))).toBe(-1);
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
it("already-at-0 member forbids any leftward move", () => {
|
|
119
|
-
expect(clampGroupMoveDelta(-4,
|
|
121
|
+
expect(clampGroupMoveDelta(-4, starts(0, 3, 7))).toBe(0);
|
|
120
122
|
// rightward still allowed
|
|
121
|
-
expect(clampGroupMoveDelta(2,
|
|
123
|
+
expect(clampGroupMoveDelta(2, starts(0, 3, 7))).toBe(2);
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
it("returns the raw delta for an empty formation", () => {
|
|
125
|
-
expect(clampGroupMoveDelta(-9,
|
|
127
|
+
expect(clampGroupMoveDelta(-9, starts())).toBe(-9);
|
|
126
128
|
});
|
|
127
129
|
});
|
|
@@ -97,10 +97,13 @@ export function multiDragPassengerOffsetPx(
|
|
|
97
97
|
* included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
|
|
98
98
|
* no fixed right wall (the composition grows on commit).
|
|
99
99
|
*/
|
|
100
|
-
export function clampGroupMoveDelta(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
export function clampGroupMoveDelta(
|
|
101
|
+
rawDelta: number,
|
|
102
|
+
members: readonly { start: number; minStart?: number }[],
|
|
103
|
+
): number {
|
|
104
|
+
if (members.length === 0) return rawDelta;
|
|
105
|
+
// The member nearest its own floor bounds the move: delta ≥ -(start - minStart) for each.
|
|
106
|
+
const room = Math.min(...members.map((m) => m.start - (m.minStart ?? 0)));
|
|
107
|
+
const minDelta = room === 0 ? 0 : -room; // avoid -0
|
|
105
108
|
return rawDelta < minDelta ? minDelta : rawDelta;
|
|
106
109
|
}
|
|
@@ -46,9 +46,13 @@ type ContainerInputs = Omit<TimelineContainerProps, "className"> & {
|
|
|
46
46
|
|
|
47
47
|
type ViewportInputs = Omit<
|
|
48
48
|
TimelineViewportProps,
|
|
49
|
-
|
|
49
|
+
| "className"
|
|
50
|
+
| "data-timeline-scroll-viewport"
|
|
51
|
+
| "data-timeline-auto-scroll-left-inset"
|
|
52
|
+
| "data-timeline-content-origin"
|
|
50
53
|
> & {
|
|
51
54
|
labelMode: boolean;
|
|
55
|
+
contentOrigin: number;
|
|
52
56
|
zoomMode: "fit" | "manual";
|
|
53
57
|
};
|
|
54
58
|
|
|
@@ -69,7 +73,7 @@ export function buildTimelineMeta({
|
|
|
69
73
|
labelColumnWidth,
|
|
70
74
|
razorGuide,
|
|
71
75
|
}: TimelineMetaBuilderInputs): TimelineMeta {
|
|
72
|
-
const { labelMode, zoomMode, ...viewportProps } = viewport;
|
|
76
|
+
const { labelMode, zoomMode, contentOrigin, ...viewportProps } = viewport;
|
|
73
77
|
const containerClassName = `relative border-t select-none h-full overflow-hidden ${container.isDragOver ? container.accentClass : ""} ${container.activeTool === "razor" ? "cursor-crosshair" : container.shiftHeld ? "cursor-crosshair" : "cursor-default"}`;
|
|
74
78
|
const viewportClassName = `${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-hidden`;
|
|
75
79
|
return {
|
|
@@ -87,6 +91,7 @@ export function buildTimelineMeta({
|
|
|
87
91
|
...viewportProps,
|
|
88
92
|
"data-timeline-scroll-viewport": true,
|
|
89
93
|
"data-timeline-auto-scroll-left-inset": labelMode ? labelColumnWidth : 0,
|
|
94
|
+
"data-timeline-content-origin": contentOrigin,
|
|
90
95
|
className: viewportClassName,
|
|
91
96
|
},
|
|
92
97
|
razorGuide,
|
|
@@ -622,7 +622,7 @@ describe("expanded sub-comp children — lane scoping", () => {
|
|
|
622
622
|
const child = (id: string, track: number, start: number, duration: number) =>
|
|
623
623
|
el(id, track, start, duration, {
|
|
624
624
|
sourceFile: "scene.html",
|
|
625
|
-
|
|
625
|
+
expandedHostKey: "host",
|
|
626
626
|
});
|
|
627
627
|
|
|
628
628
|
it("a child mirrors onto a SIBLING's lane and persists the sibling's AUTHORED track", () => {
|
|
@@ -630,7 +630,7 @@ describe("expanded sub-comp children — lane scoping", () => {
|
|
|
630
630
|
// file) but sits elsewhere in time → its lane is free over t's span.
|
|
631
631
|
const c1 = el("c1", 0.25, 20, 3, {
|
|
632
632
|
sourceFile: "scene.html",
|
|
633
|
-
|
|
633
|
+
expandedHostKey: "host",
|
|
634
634
|
authoredTrack: 2,
|
|
635
635
|
});
|
|
636
636
|
const c2 = child("c2", 0.5, 5, 5);
|
|
@@ -102,7 +102,7 @@ function expandedChildAllowedLanes(
|
|
|
102
102
|
element: TimelineElement,
|
|
103
103
|
elements: TimelineElement[],
|
|
104
104
|
): ReadonlySet<number> | null {
|
|
105
|
-
if (element.
|
|
105
|
+
if (element.expandedHostKey == null) return null;
|
|
106
106
|
const selfKey = keyOf(element);
|
|
107
107
|
return new Set(
|
|
108
108
|
elements
|
|
@@ -513,9 +513,9 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
513
513
|
const h = renderResizeHarness([a], [], { onSeek });
|
|
514
514
|
h.startResize(a, "end");
|
|
515
515
|
h.movePointer(50);
|
|
516
|
-
expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true });
|
|
516
|
+
expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true, follow: false });
|
|
517
517
|
await h.dropPointer();
|
|
518
|
-
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
|
|
518
|
+
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
|
|
519
519
|
h.unmount();
|
|
520
520
|
});
|
|
521
521
|
|
|
@@ -525,7 +525,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
525
525
|
const h = renderResizeHarness([a], [], { onSeek });
|
|
526
526
|
h.startResize(a, "start");
|
|
527
527
|
h.movePointer(50);
|
|
528
|
-
expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true });
|
|
528
|
+
expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true, follow: false });
|
|
529
529
|
h.unmount();
|
|
530
530
|
});
|
|
531
531
|
|
|
@@ -545,7 +545,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
545
545
|
h.movePointer(80);
|
|
546
546
|
h.movePointer(120);
|
|
547
547
|
await h.dropPointer();
|
|
548
|
-
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
|
|
548
|
+
expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false });
|
|
549
549
|
h.unmount();
|
|
550
550
|
});
|
|
551
551
|
|
|
@@ -571,7 +571,10 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => {
|
|
|
571
571
|
const clip = h.getResizingClip()!;
|
|
572
572
|
expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
|
|
573
573
|
expect(clip).toMatchObject({ snapTime: null, snapType: null });
|
|
574
|
-
expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), {
|
|
574
|
+
expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), {
|
|
575
|
+
keepPlaying: true,
|
|
576
|
+
follow: false,
|
|
577
|
+
});
|
|
575
578
|
h.unmount();
|
|
576
579
|
});
|
|
577
580
|
});
|
|
@@ -66,7 +66,7 @@ interface UseTimelineClipDragInput {
|
|
|
66
66
|
onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
67
67
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
|
|
68
68
|
/** Seeks the preview; a trim shows the frame at its dragged edge. */
|
|
69
|
-
onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
69
|
+
onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
|
|
70
70
|
setShowPopover: (show: boolean) => void;
|
|
71
71
|
/** Stable ref to the range selection setter — wired after mount to break circular dependency. */
|
|
72
72
|
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
|
|
@@ -311,6 +311,7 @@ export function useTimelineClipDrag({
|
|
|
311
311
|
// and it only resumes playback if it was already playing.
|
|
312
312
|
onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
|
|
313
313
|
keepPlaying: true,
|
|
314
|
+
follow: false,
|
|
314
315
|
});
|
|
315
316
|
publishResizingClip(
|
|
316
317
|
resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
|
|
@@ -361,7 +362,7 @@ export function useTimelineClipDrag({
|
|
|
361
362
|
if (trimSeekOriginRef.current != null) {
|
|
362
363
|
// Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
|
|
363
364
|
if (!usePlayerStore.getState().isPlaying) {
|
|
364
|
-
onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true });
|
|
365
|
+
onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false });
|
|
365
366
|
}
|
|
366
367
|
trimSeekOriginRef.current = null;
|
|
367
368
|
}
|