@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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
3
|
+
import { canMoveTimelineElement } from "./timelineAuthoredMoveTarget";
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Pure geometry for the LIVE multi-selection drag preview.
|
|
3
7
|
*
|
|
@@ -9,11 +13,11 @@
|
|
|
9
13
|
* behind individually — the whole formation moves by one delta, spacing locked.
|
|
10
14
|
*
|
|
11
15
|
* That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
|
|
12
|
-
* The preview start is ALREADY group-clamped upstream (
|
|
13
|
-
*
|
|
16
|
+
* The preview start is ALREADY group-clamped upstream (computeDragPreview floors it
|
|
17
|
+
* at groupMoveFloor), so this delta is the clamped delta:
|
|
14
18
|
* the instant any member would cross 0 the grabbed clip stops and every passenger
|
|
15
19
|
* stops with it — the formation never deforms. On DROP the commit shifts every
|
|
16
|
-
*
|
|
20
|
+
* moving clip (resolveGroupMovers) by this same delta (see timelineClipDragCommit).
|
|
17
21
|
*
|
|
18
22
|
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
|
|
19
23
|
* keep their lanes, so only their x moves.
|
|
@@ -81,26 +85,23 @@ export function multiDragPassengerOffsetPx(
|
|
|
81
85
|
return multiDragDeltaSeconds(input) * pps;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const minStart = Math.min(...memberStarts);
|
|
104
|
-
const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
|
|
105
|
-
return rawDelta < minDelta ? minDelta : rawDelta;
|
|
88
|
+
/** The selected clips a group drag moves: a locked clip swept into the selection stays put.
|
|
89
|
+
* Null when the grabbed clip is not part of a multi-selection. */
|
|
90
|
+
export function resolveGroupMovers(
|
|
91
|
+
elements: readonly TimelineElement[],
|
|
92
|
+
selectedKeys: ReadonlySet<string> | null | undefined,
|
|
93
|
+
dragKey: string,
|
|
94
|
+
): TimelineElement[] | null {
|
|
95
|
+
if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
|
|
96
|
+
return elements.filter((e) => selectedKeys.has(e.key ?? e.id) && canMoveTimelineElement(e));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The lowest start the grabbed clip may take so the whole group moves rigidly and no clip
|
|
100
|
+
* crosses its own floor, its host composition's start (the commit floors each one there). */
|
|
101
|
+
export function groupMoveFloor(
|
|
102
|
+
grabbed: TimelineElement,
|
|
103
|
+
movers: readonly TimelineElement[],
|
|
104
|
+
): number {
|
|
105
|
+
const room = Math.min(...[grabbed, ...movers].map((e) => e.start - clampToHostStart(e, 0)));
|
|
106
|
+
return grabbed.start - room;
|
|
106
107
|
}
|
|
@@ -5,23 +5,26 @@ import type {
|
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
8
|
-
import type
|
|
8
|
+
import { resolveGroupMovers, type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
9
9
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
10
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
10
11
|
|
|
11
12
|
export function resolveMultiDragPreview(
|
|
12
13
|
draggedClip: DraggedClipState | null,
|
|
13
14
|
selectedKeys: ReadonlySet<string>,
|
|
15
|
+
elements: readonly TimelineElement[],
|
|
14
16
|
): MultiDragPreviewInput | null {
|
|
15
|
-
// The dragged clip is a free ghost;
|
|
16
|
-
// clamped delta so the formation stays rigid
|
|
17
|
+
// The dragged clip is a free ghost; the clips that move with it follow the same
|
|
18
|
+
// clamped delta so the formation stays rigid. A locked selected clip stays put.
|
|
17
19
|
if (!draggedClip?.started) return null;
|
|
18
20
|
const draggedKey = getTimelineElementIdentity(draggedClip.element);
|
|
21
|
+
const movers = resolveGroupMovers(elements, selectedKeys, draggedKey);
|
|
19
22
|
return {
|
|
20
23
|
dragStarted: true,
|
|
21
24
|
draggedKey,
|
|
22
25
|
draggedOriginStart: draggedClip.element.start,
|
|
23
26
|
draggedPreviewStart: draggedClip.previewStart,
|
|
24
|
-
selectedKeys,
|
|
27
|
+
selectedKeys: movers ? new Set(movers.map(getTimelineElementIdentity)) : selectedKeys,
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -46,9 +49,13 @@ type ContainerInputs = Omit<TimelineContainerProps, "className"> & {
|
|
|
46
49
|
|
|
47
50
|
type ViewportInputs = Omit<
|
|
48
51
|
TimelineViewportProps,
|
|
49
|
-
|
|
52
|
+
| "className"
|
|
53
|
+
| "data-timeline-scroll-viewport"
|
|
54
|
+
| "data-timeline-auto-scroll-left-inset"
|
|
55
|
+
| "data-timeline-content-origin"
|
|
50
56
|
> & {
|
|
51
57
|
labelMode: boolean;
|
|
58
|
+
contentOrigin: number;
|
|
52
59
|
zoomMode: "fit" | "manual";
|
|
53
60
|
};
|
|
54
61
|
|
|
@@ -69,7 +76,7 @@ export function buildTimelineMeta({
|
|
|
69
76
|
labelColumnWidth,
|
|
70
77
|
razorGuide,
|
|
71
78
|
}: TimelineMetaBuilderInputs): TimelineMeta {
|
|
72
|
-
const { labelMode, zoomMode, ...viewportProps } = viewport;
|
|
79
|
+
const { labelMode, zoomMode, contentOrigin, ...viewportProps } = viewport;
|
|
73
80
|
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
81
|
const viewportClassName = `${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-hidden`;
|
|
75
82
|
return {
|
|
@@ -87,6 +94,7 @@ export function buildTimelineMeta({
|
|
|
87
94
|
...viewportProps,
|
|
88
95
|
"data-timeline-scroll-viewport": true,
|
|
89
96
|
"data-timeline-auto-scroll-left-inset": labelMode ? labelColumnWidth : 0,
|
|
97
|
+
"data-timeline-content-origin": contentOrigin,
|
|
90
98
|
className: viewportClassName,
|
|
91
99
|
},
|
|
92
100
|
razorGuide,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { MutableRefObject, PointerEvent } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TimelineContainerProps,
|
|
4
|
+
TimelineMeta,
|
|
5
|
+
TimelineViewportProps,
|
|
6
|
+
} from "./TimelineProvider";
|
|
7
|
+
import {
|
|
8
|
+
buildTimelineMeta,
|
|
9
|
+
shouldIgnoreTimelinePointerDown,
|
|
10
|
+
} from "./timelineProviderStateBuilders";
|
|
11
|
+
import { TimelineRazorGuideOverlay } from "./TimelineRazorInteraction";
|
|
12
|
+
import { LABEL_COL_W } from "./timelineLayout";
|
|
13
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
14
|
+
|
|
15
|
+
type ViewportHandlers = Pick<
|
|
16
|
+
TimelineViewportProps,
|
|
17
|
+
"onDragOver" | "onDragLeave" | "onDrop" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export interface TimelineShellMetaInputs {
|
|
21
|
+
isDragOver: boolean;
|
|
22
|
+
hasFileDrop: boolean;
|
|
23
|
+
drop: Pick<ViewportHandlers, "onDragOver" | "onDragLeave" | "onDrop">;
|
|
24
|
+
pointer: Pick<ViewportHandlers, "onPointerMove" | "onPointerUp" | "onPointerCancel"> & {
|
|
25
|
+
onPointerDown: TimelineViewportProps["onPointerDown"];
|
|
26
|
+
};
|
|
27
|
+
setContainerRef: TimelineContainerProps["ref"];
|
|
28
|
+
setScrollRef: TimelineViewportProps["ref"];
|
|
29
|
+
focusProps: Pick<TimelineViewportProps, "onFocus" | "onBlur">;
|
|
30
|
+
elementCount: number;
|
|
31
|
+
activeTool: string;
|
|
32
|
+
shiftHeld: boolean;
|
|
33
|
+
labelMode: boolean;
|
|
34
|
+
contentOrigin: number;
|
|
35
|
+
zoomMode: "fit" | "manual";
|
|
36
|
+
theme: Pick<TimelineTheme, "shellBackground" | "shellBorder">;
|
|
37
|
+
razor: {
|
|
38
|
+
razorGuideX: number | null;
|
|
39
|
+
updateRazorGuide: TimelineContainerProps["onMouseMove"];
|
|
40
|
+
clearRazorGuide: () => void;
|
|
41
|
+
splitAllAtPointer: (event: PointerEvent<HTMLDivElement>) => boolean;
|
|
42
|
+
};
|
|
43
|
+
lastScrollLeftRef: MutableRefObject<number>;
|
|
44
|
+
recordTimelineScroll: (scroll: HTMLDivElement) => void;
|
|
45
|
+
syncScrollViewport: (scroll: HTMLDivElement, isScrolling?: boolean) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The timeline shell's container and scroll viewport props: drop, pointer, scroll and razor wiring. */
|
|
49
|
+
export function buildTimelineShellMeta(input: TimelineShellMetaInputs): TimelineMeta {
|
|
50
|
+
const { drop, pointer, razor, theme, activeTool } = input;
|
|
51
|
+
return buildTimelineMeta({
|
|
52
|
+
emptyState: { isDragOver: input.isDragOver, onFileDrop: input.hasFileDrop, ...drop },
|
|
53
|
+
container: {
|
|
54
|
+
ref: input.setContainerRef,
|
|
55
|
+
"aria-label": "Timeline track view",
|
|
56
|
+
"data-timeline-element-count": input.elementCount,
|
|
57
|
+
isDragOver: input.isDragOver,
|
|
58
|
+
activeTool,
|
|
59
|
+
shiftHeld: input.shiftHeld,
|
|
60
|
+
accentClass: "ring-1 ring-inset ring-studio-accent/60",
|
|
61
|
+
onMouseMove: razor.updateRazorGuide,
|
|
62
|
+
onMouseLeave: razor.clearRazorGuide,
|
|
63
|
+
style: {
|
|
64
|
+
touchAction: "pan-x pan-y",
|
|
65
|
+
background: theme.shellBackground,
|
|
66
|
+
borderColor: theme.shellBorder,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
viewport: {
|
|
70
|
+
ref: input.setScrollRef,
|
|
71
|
+
tabIndex: -1,
|
|
72
|
+
labelMode: input.labelMode,
|
|
73
|
+
contentOrigin: input.contentOrigin,
|
|
74
|
+
zoomMode: input.zoomMode,
|
|
75
|
+
onScroll: (e) => {
|
|
76
|
+
input.lastScrollLeftRef.current = e.currentTarget.scrollLeft;
|
|
77
|
+
input.recordTimelineScroll(e.currentTarget);
|
|
78
|
+
input.syncScrollViewport(e.currentTarget, true);
|
|
79
|
+
},
|
|
80
|
+
...input.focusProps,
|
|
81
|
+
...drop,
|
|
82
|
+
onPointerDown: (e) => {
|
|
83
|
+
if (shouldIgnoreTimelinePointerDown(e.target)) return;
|
|
84
|
+
if (razor.splitAllAtPointer(e)) return;
|
|
85
|
+
pointer.onPointerDown(e);
|
|
86
|
+
},
|
|
87
|
+
onPointerMove: pointer.onPointerMove,
|
|
88
|
+
onPointerUp: pointer.onPointerUp,
|
|
89
|
+
onPointerCancel: pointer.onPointerCancel,
|
|
90
|
+
onLostPointerCapture: pointer.onPointerCancel,
|
|
91
|
+
},
|
|
92
|
+
elementCount: input.elementCount,
|
|
93
|
+
labelColumnWidth: LABEL_COL_W,
|
|
94
|
+
razorGuide:
|
|
95
|
+
activeTool === "razor" && razor.razorGuideX !== null ? (
|
|
96
|
+
<TimelineRazorGuideOverlay x={razor.razorGuideX} />
|
|
97
|
+
) : null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
4
|
+
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
5
|
+
import {
|
|
6
|
+
resolveMultiDragPreview,
|
|
7
|
+
resolveResizingElementIds,
|
|
8
|
+
} from "./timelineProviderStateBuilders";
|
|
9
|
+
import { getTimelinePreviewElement } from "./timelineViewModel";
|
|
10
|
+
import { resolveSnapGuide } from "./timelineSnapping";
|
|
11
|
+
|
|
12
|
+
/** What the lanes draw for a live move or trim: the ghost, the clips riding along, the snap guide. */
|
|
13
|
+
export function useTimelineDragPreviewState(
|
|
14
|
+
draggedClip: DraggedClipState | null,
|
|
15
|
+
resizingClip: ResizingClipState | null,
|
|
16
|
+
selectedElementIds: ReadonlySet<string>,
|
|
17
|
+
timelineElements: readonly TimelineElement[],
|
|
18
|
+
) {
|
|
19
|
+
const getPreviewElement = useCallback(
|
|
20
|
+
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
|
21
|
+
[resizingClip],
|
|
22
|
+
);
|
|
23
|
+
const multiDragPreview = useMemo(
|
|
24
|
+
() => resolveMultiDragPreview(draggedClip, selectedElementIds, timelineElements),
|
|
25
|
+
[draggedClip, selectedElementIds, timelineElements],
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
resizingElementIds: resolveResizingElementIds(resizingClip),
|
|
29
|
+
getPreviewElement,
|
|
30
|
+
draggedElement: draggedClip?.element ?? null,
|
|
31
|
+
multiDragPreview,
|
|
32
|
+
snapGuide: resolveSnapGuide(draggedClip, resizingClip),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { act,
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { act, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
6
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
7
8
|
|
|
8
9
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
10
|
|
|
11
|
+
const ORIGIN = 32;
|
|
12
|
+
|
|
10
13
|
function scrollBox(scrollLeft: number) {
|
|
11
14
|
const el = document.createElement("div");
|
|
12
15
|
let left = scrollLeft;
|
|
@@ -18,18 +21,34 @@ function scrollBox(scrollLeft: number) {
|
|
|
18
21
|
return el;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
interface HarnessProps {
|
|
25
|
+
pps: number;
|
|
26
|
+
scroll: HTMLDivElement;
|
|
27
|
+
percent?: number;
|
|
28
|
+
dragging?: boolean;
|
|
29
|
+
zoomMode?: ZoomMode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Harness({
|
|
33
|
+
pps,
|
|
34
|
+
scroll,
|
|
35
|
+
percent = 100,
|
|
36
|
+
dragging = false,
|
|
37
|
+
zoomMode = "manual",
|
|
38
|
+
}: HarnessProps) {
|
|
39
|
+
const scrollRef = useRef(scroll);
|
|
40
|
+
const durationRef = useRef(60);
|
|
22
41
|
useTimelinePlayhead({
|
|
23
|
-
playheadRef:
|
|
24
|
-
scrollRef
|
|
42
|
+
playheadRef: { current: document.createElement("div") },
|
|
43
|
+
scrollRef,
|
|
25
44
|
ppsRef: { current: pps },
|
|
26
|
-
durationRef
|
|
27
|
-
isDragging: { current:
|
|
45
|
+
durationRef,
|
|
46
|
+
isDragging: { current: dragging },
|
|
28
47
|
currentTime: 0,
|
|
29
|
-
zoomMode
|
|
30
|
-
manualZoomPercent:
|
|
31
|
-
zoomModeRef: { current:
|
|
32
|
-
manualZoomPercentRef: { current:
|
|
48
|
+
zoomMode,
|
|
49
|
+
manualZoomPercent: percent,
|
|
50
|
+
zoomModeRef: { current: zoomMode },
|
|
51
|
+
manualZoomPercentRef: { current: percent },
|
|
33
52
|
fitPps: pps,
|
|
34
53
|
fitPpsRef: { current: pps },
|
|
35
54
|
effectiveDuration: 60,
|
|
@@ -38,28 +57,198 @@ function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) {
|
|
|
38
57
|
elementsLength: 1,
|
|
39
58
|
setZoomMode: () => {},
|
|
40
59
|
setManualZoomPercent: () => {},
|
|
41
|
-
contentOrigin:
|
|
60
|
+
contentOrigin: ORIGIN,
|
|
42
61
|
});
|
|
43
62
|
return null;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
act(() => root.render(<Harness
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
const roots: Root[] = [];
|
|
66
|
+
function mount(props: HarnessProps) {
|
|
67
|
+
const root = createRoot(document.createElement("div"));
|
|
68
|
+
roots.push(root);
|
|
69
|
+
act(() => root.render(<Harness {...props} />));
|
|
70
|
+
return (next: Partial<HarnessProps>, byPerson = false) =>
|
|
71
|
+
act(() => {
|
|
72
|
+
if (byPerson) usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 }));
|
|
73
|
+
root.render(<Harness {...props} {...next} />);
|
|
74
|
+
});
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
usePlayerStore.setState({ currentTime: 0, isPlaying: false, beatDragging: false });
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Where the playhead at `time` sits inside the 800px viewport. */
|
|
85
|
+
const onScreenX = (scroll: HTMLDivElement, time: number, pps: number) =>
|
|
86
|
+
ORIGIN + time * pps - scroll.scrollLeft;
|
|
87
|
+
function expectVisible(scroll: HTMLDivElement, time: number, pps: number) {
|
|
88
|
+
const x = onScreenX(scroll, time, pps);
|
|
89
|
+
expect(x).toBeGreaterThanOrEqual(ORIGIN);
|
|
90
|
+
expect(x).toBeLessThanOrEqual(800);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("useTimelinePlayhead zoom anchor", () => {
|
|
94
|
+
it("keeps a view at the start at the start when the window resizes", () => {
|
|
95
|
+
const scroll = scrollBox(0);
|
|
96
|
+
mount({ pps: 100, scroll })({ pps: 114 });
|
|
97
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
59
98
|
});
|
|
60
99
|
|
|
61
|
-
it("keeps the time at the viewport centre when
|
|
100
|
+
it("keeps the time at the viewport centre when a scrolled window resizes", () => {
|
|
101
|
+
const scroll = scrollBox(400);
|
|
62
102
|
// Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400.
|
|
63
|
-
|
|
103
|
+
mount({ pps: 100, scroll })({ pps: 200 });
|
|
104
|
+
expect(scroll.scrollLeft).toBe(1168);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps a view at 00:00 on a resize even when the playhead is mid-film", () => {
|
|
108
|
+
usePlayerStore.setState({ currentTime: 6 });
|
|
109
|
+
const scroll = scrollBox(0);
|
|
110
|
+
mount({ pps: 100, scroll })({ pps: 150 });
|
|
111
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps the playhead where it is on screen when the toolbar zooms", () => {
|
|
115
|
+
usePlayerStore.setState({ currentTime: 6 });
|
|
116
|
+
const scroll = scrollBox(400);
|
|
117
|
+
const before = onScreenX(scroll, 6, 100);
|
|
118
|
+
mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
|
|
119
|
+
expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => {
|
|
123
|
+
const scroll = scrollBox(0);
|
|
124
|
+
mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }, true);
|
|
125
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("keeps the centre on a resize after a toolbar zoom that hit the zoom limit", () => {
|
|
129
|
+
usePlayerStore.setState({ currentTime: 6 });
|
|
130
|
+
const scroll = scrollBox(400);
|
|
131
|
+
const update = mount({ pps: 100, scroll });
|
|
132
|
+
update({}, true);
|
|
133
|
+
update({ pps: 200 });
|
|
134
|
+
expect(scroll.scrollLeft).toBe(1168);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("brings an off-screen playhead into view when the toolbar zooms", () => {
|
|
138
|
+
usePlayerStore.setState({ currentTime: 30 });
|
|
139
|
+
const scroll = scrollBox(0);
|
|
140
|
+
mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true);
|
|
141
|
+
expectVisible(scroll, 30, 200);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", () => {
|
|
146
|
+
it("keeps 00:00 when the window resizes after an edit pinned the zoom", () => {
|
|
147
|
+
usePlayerStore.setState({ currentTime: 6 });
|
|
148
|
+
const scroll = scrollBox(0);
|
|
149
|
+
const update = mount({ pps: 100, scroll, percent: 200 });
|
|
150
|
+
update({ percent: 100 });
|
|
151
|
+
update({ pps: 130, percent: 100 });
|
|
152
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves an off-screen playhead alone when a length change re-pins the zoom", () => {
|
|
156
|
+
usePlayerStore.setState({ currentTime: 30 });
|
|
157
|
+
const scroll = scrollBox(400);
|
|
158
|
+
const update = mount({ pps: 100, scroll, percent: 150 });
|
|
159
|
+
update({ pps: 90, percent: 150 });
|
|
160
|
+
update({ pps: 101, percent: 168 });
|
|
161
|
+
const x = onScreenX(scroll, 30, 101);
|
|
162
|
+
expect(x > 800 || x < ORIGIN).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("anchors a person's zoom that lands on the percent already stored", () => {
|
|
166
|
+
usePlayerStore.setState({ currentTime: 30 });
|
|
167
|
+
const scroll = scrollBox(0);
|
|
168
|
+
mount({ pps: 100, scroll, percent: 200 })({ pps: 200, percent: 200 }, true);
|
|
169
|
+
expectVisible(scroll, 30, 200);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("useTimelinePlayhead follow while paused", () => {
|
|
174
|
+
it("scrolls a paused seek that lands off screen into view", () => {
|
|
175
|
+
const scroll = scrollBox(0);
|
|
176
|
+
mount({ pps: 100, scroll });
|
|
177
|
+
act(() => liveTime.notifySeek(30));
|
|
178
|
+
expectVisible(scroll, 30, 100);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => {
|
|
182
|
+
const scroll = scrollBox(0);
|
|
183
|
+
mount({ pps: 100, scroll });
|
|
184
|
+
act(() => liveTime.notifySeek(7));
|
|
185
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("scrolls back to the playhead when a person seeks to the time it already has", () => {
|
|
189
|
+
const scroll = scrollBox(0);
|
|
190
|
+
mount({ pps: 100, scroll });
|
|
191
|
+
act(() => liveTime.notifySeek(30));
|
|
192
|
+
scroll.scrollLeft = 0;
|
|
193
|
+
act(() => liveTime.notifySeek(30));
|
|
194
|
+
expectVisible(scroll, 30, 100);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("keeps a person's scroll when a reload republishes the seek rounded to a frame", () => {
|
|
198
|
+
const scroll = scrollBox(0);
|
|
199
|
+
mount({ pps: 100, scroll });
|
|
200
|
+
act(() => liveTime.notifySeek(12.3456));
|
|
201
|
+
scroll.scrollLeft = 0;
|
|
202
|
+
act(() => liveTime.notify(Math.floor(12.3456 * 30) / 30));
|
|
203
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("keeps a person's scroll when a reload follows a keyboard pause", () => {
|
|
207
|
+
const scroll = scrollBox(0);
|
|
208
|
+
mount({ pps: 100, scroll });
|
|
209
|
+
act(() => usePlayerStore.setState({ isPlaying: true }));
|
|
210
|
+
act(() => liveTime.notify(30));
|
|
211
|
+
act(() => {
|
|
212
|
+
usePlayerStore.getState().setCurrentTime(30.012);
|
|
213
|
+
usePlayerStore.setState({ isPlaying: false });
|
|
214
|
+
});
|
|
215
|
+
scroll.scrollLeft = 0;
|
|
216
|
+
act(() => liveTime.notify(30.012));
|
|
217
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keeps a person's scroll when a reload stops a reverse shuttle", () => {
|
|
221
|
+
usePlayerStore.setState({ currentTime: 30 });
|
|
222
|
+
const scroll = scrollBox(0);
|
|
223
|
+
mount({ pps: 100, scroll });
|
|
224
|
+
act(() => usePlayerStore.setState({ isPlaying: true }));
|
|
225
|
+
act(() => liveTime.notify(29));
|
|
226
|
+
act(() => liveTime.notify(20));
|
|
227
|
+
act(() => usePlayerStore.setState({ isPlaying: false }));
|
|
228
|
+
scroll.scrollLeft = 0;
|
|
229
|
+
act(() => liveTime.notify(20));
|
|
230
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not scroll while the playhead is being dragged", () => {
|
|
234
|
+
const scroll = scrollBox(0);
|
|
235
|
+
mount({ pps: 100, scroll, dragging: true });
|
|
236
|
+
act(() => liveTime.notifySeek(30));
|
|
237
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("does not scroll while a beat is being dragged past the edge", () => {
|
|
241
|
+
usePlayerStore.setState({ beatDragging: true });
|
|
242
|
+
const scroll = scrollBox(0);
|
|
243
|
+
mount({ pps: 100, scroll });
|
|
244
|
+
act(() => liveTime.notifySeek(30));
|
|
245
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does not scroll in Fit", () => {
|
|
249
|
+
const scroll = scrollBox(0);
|
|
250
|
+
mount({ pps: 100, scroll, zoomMode: "fit" });
|
|
251
|
+
act(() => liveTime.notifySeek(30));
|
|
252
|
+
expect(scroll.scrollLeft).toBe(0);
|
|
64
253
|
});
|
|
65
254
|
});
|