@hyperframes/studio 0.7.85 → 0.7.87
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-CXVkvR6C.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-CVcz5MoC.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-DloBOPGY.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-BZFIup06.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6266 -4923
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePerformanceTelemetry.test.ts +68 -2
- package/src/player/components/useTimelinePerformanceTelemetry.ts +1 -1
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- package/dist/assets/index-D379YOKT.css +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import type { SetStateAction } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
|
|
7
|
+
import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
document.body.replaceChildren();
|
|
11
|
+
usePlayerStore.getState().reset();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("timeline clip drag gesture lifecycle", () => {
|
|
15
|
+
it("finishes a drag after virtualization unmounts its source row", () => {
|
|
16
|
+
const element: TimelineElement = {
|
|
17
|
+
id: "clip-1",
|
|
18
|
+
tag: "div",
|
|
19
|
+
start: 0,
|
|
20
|
+
duration: 2,
|
|
21
|
+
track: 0,
|
|
22
|
+
};
|
|
23
|
+
const drag: DraggedClipState = {
|
|
24
|
+
element,
|
|
25
|
+
originClientX: 0,
|
|
26
|
+
originClientY: 0,
|
|
27
|
+
originScrollLeft: 0,
|
|
28
|
+
originScrollTop: 0,
|
|
29
|
+
pointerClientX: 0,
|
|
30
|
+
pointerClientY: 0,
|
|
31
|
+
pointerOffsetX: 0,
|
|
32
|
+
pointerOffsetY: 0,
|
|
33
|
+
previewStart: 1,
|
|
34
|
+
previewTrack: 0,
|
|
35
|
+
desiredTrack: 0,
|
|
36
|
+
insertRow: null,
|
|
37
|
+
snapTime: null,
|
|
38
|
+
snapType: null,
|
|
39
|
+
started: true,
|
|
40
|
+
};
|
|
41
|
+
const draggedClipRef = { current: drag as DraggedClipState | null };
|
|
42
|
+
const resizingClipRef = { current: null as ResizingClipState | null };
|
|
43
|
+
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
|
|
44
|
+
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
|
|
45
|
+
};
|
|
46
|
+
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
|
|
47
|
+
...previous,
|
|
48
|
+
previewStart: 3,
|
|
49
|
+
}));
|
|
50
|
+
const onMoveElement = vi.fn();
|
|
51
|
+
const stopAutoScroll = vi.fn();
|
|
52
|
+
const dispose = mountTimelineClipDragGestureLifecycle({
|
|
53
|
+
draggedClipRef,
|
|
54
|
+
resizingClipRef,
|
|
55
|
+
blockedClipRef: { current: null },
|
|
56
|
+
groupResizeRef: { current: null },
|
|
57
|
+
suppressClickRef: { current: false },
|
|
58
|
+
elementsRef: { current: [element] },
|
|
59
|
+
trackOrderRef: { current: [0] },
|
|
60
|
+
setDraggedClip,
|
|
61
|
+
setResizingClip: () => {},
|
|
62
|
+
setShowPopover: () => {},
|
|
63
|
+
setRangeSelectionRef: { current: null },
|
|
64
|
+
applyResizePointerRef: { current: () => {} },
|
|
65
|
+
syncClipDragAutoScrollRef: { current: () => {} },
|
|
66
|
+
stopClipDragAutoScrollRef: { current: stopAutoScroll },
|
|
67
|
+
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
|
|
68
|
+
restoreGroupResizeMembers: () => {},
|
|
69
|
+
updateElement: vi.fn(),
|
|
70
|
+
onMoveElementRef: { current: onMoveElement },
|
|
71
|
+
onMoveElementsRef: { current: undefined },
|
|
72
|
+
onResizeElementRef: { current: undefined },
|
|
73
|
+
onResizeElementsRef: { current: undefined },
|
|
74
|
+
onBlockedEditAttemptRef: { current: undefined },
|
|
75
|
+
readZIndexRef: { current: undefined },
|
|
76
|
+
onStackingPatchesRef: { current: undefined },
|
|
77
|
+
refreshAfterLaneMoveRef: { current: undefined },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const sourceRow = document.createElement("div");
|
|
81
|
+
sourceRow.dataset.timelineRow = "";
|
|
82
|
+
sourceRow.append(document.createElement("div"));
|
|
83
|
+
document.body.append(sourceRow);
|
|
84
|
+
sourceRow.remove();
|
|
85
|
+
|
|
86
|
+
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
|
|
87
|
+
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(draggedClipRef.current?.previewStart).toBe(3);
|
|
89
|
+
|
|
90
|
+
window.dispatchEvent(new MouseEvent("pointerup"));
|
|
91
|
+
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
|
|
92
|
+
expect(draggedClipRef.current).toBeNull();
|
|
93
|
+
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
|
|
94
|
+
|
|
95
|
+
dispose();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { Dispatch, RefObject, SetStateAction } from "react";
|
|
2
|
+
import { resolveTimelineDragEscape } from "./timelineEditing";
|
|
3
|
+
import { commitDraggedClipMove } from "./timelineClipDragCommit";
|
|
4
|
+
import type {
|
|
5
|
+
BlockedClipState,
|
|
6
|
+
DraggedClipState,
|
|
7
|
+
ResizingClipState,
|
|
8
|
+
} from "./timelineClipDragTypes";
|
|
9
|
+
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
|
|
10
|
+
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
|
|
11
|
+
import {
|
|
12
|
+
beginTimelineOptimisticGesture,
|
|
13
|
+
rollbackLatestTimelineOptimisticGesture,
|
|
14
|
+
} from "./timelineOptimisticRevision";
|
|
15
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
16
|
+
import type { StackingPatch } from "./timelineStackingSync";
|
|
17
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
18
|
+
|
|
19
|
+
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
|
|
20
|
+
|
|
21
|
+
interface TimelineClipDragGestureLifecycleInput {
|
|
22
|
+
draggedClipRef: RefObject<DraggedClipState | null>;
|
|
23
|
+
resizingClipRef: RefObject<ResizingClipState | null>;
|
|
24
|
+
blockedClipRef: RefObject<BlockedClipState | null>;
|
|
25
|
+
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
|
|
26
|
+
suppressClickRef: RefObject<boolean>;
|
|
27
|
+
elementsRef: RefObject<TimelineElement[]>;
|
|
28
|
+
trackOrderRef: RefObject<number[]>;
|
|
29
|
+
setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
|
|
30
|
+
setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
|
|
31
|
+
setShowPopover: (show: boolean) => void;
|
|
32
|
+
setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
|
|
33
|
+
applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
|
|
34
|
+
syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>;
|
|
35
|
+
stopClipDragAutoScrollRef: RefObject<() => void>;
|
|
36
|
+
updateDraggedClipPreviewRef: RefObject<
|
|
37
|
+
(drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
|
|
38
|
+
>;
|
|
39
|
+
restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
|
|
40
|
+
updateElement: UpdateElement;
|
|
41
|
+
onMoveElementRef: RefObject<TimelineEditCallbacks["onMoveElement"]>;
|
|
42
|
+
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
|
|
43
|
+
onResizeElementRef: RefObject<TimelineEditCallbacks["onResizeElement"]>;
|
|
44
|
+
onResizeElementsRef: RefObject<TimelineEditCallbacks["onResizeElements"]>;
|
|
45
|
+
onBlockedEditAttemptRef: RefObject<
|
|
46
|
+
((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
|
|
47
|
+
>;
|
|
48
|
+
readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
|
|
49
|
+
onStackingPatchesRef: RefObject<
|
|
50
|
+
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
|
51
|
+
>;
|
|
52
|
+
refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function mountTimelineClipDragGestureLifecycle({
|
|
56
|
+
// The explicit destructuring mirrors the single call site's dependency object by design.
|
|
57
|
+
// fallow-ignore-next-line code-duplication
|
|
58
|
+
draggedClipRef,
|
|
59
|
+
resizingClipRef,
|
|
60
|
+
blockedClipRef,
|
|
61
|
+
groupResizeRef,
|
|
62
|
+
suppressClickRef,
|
|
63
|
+
elementsRef,
|
|
64
|
+
trackOrderRef,
|
|
65
|
+
setDraggedClip,
|
|
66
|
+
setResizingClip,
|
|
67
|
+
setShowPopover,
|
|
68
|
+
setRangeSelectionRef,
|
|
69
|
+
applyResizePointerRef,
|
|
70
|
+
syncClipDragAutoScrollRef,
|
|
71
|
+
stopClipDragAutoScrollRef,
|
|
72
|
+
updateDraggedClipPreviewRef,
|
|
73
|
+
restoreGroupResizeMembers,
|
|
74
|
+
updateElement,
|
|
75
|
+
onMoveElementRef,
|
|
76
|
+
onMoveElementsRef,
|
|
77
|
+
onResizeElementRef,
|
|
78
|
+
onResizeElementsRef,
|
|
79
|
+
onBlockedEditAttemptRef,
|
|
80
|
+
readZIndexRef,
|
|
81
|
+
onStackingPatchesRef,
|
|
82
|
+
refreshAfterLaneMoveRef,
|
|
83
|
+
}: TimelineClipDragGestureLifecycleInput): () => void {
|
|
84
|
+
const clearSuppressedClick = () => {
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
suppressClickRef.current = false;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
|
|
91
|
+
const distance = Math.abs(event.clientX - resize.originClientX);
|
|
92
|
+
if (!resize.started && distance < 2) return;
|
|
93
|
+
setShowPopover(false);
|
|
94
|
+
setRangeSelectionRef.current?.(null);
|
|
95
|
+
applyResizePointerRef.current(resize, event.clientX);
|
|
96
|
+
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => {
|
|
100
|
+
const distance = Math.hypot(
|
|
101
|
+
event.clientX - blocked.originClientX,
|
|
102
|
+
event.clientY - blocked.originClientY,
|
|
103
|
+
);
|
|
104
|
+
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
105
|
+
if (!blocked.started && distance < threshold) return;
|
|
106
|
+
if (!blocked.started) {
|
|
107
|
+
blocked.started = true;
|
|
108
|
+
blockedClipRef.current = blocked;
|
|
109
|
+
suppressClickRef.current = true;
|
|
110
|
+
setShowPopover(false);
|
|
111
|
+
setRangeSelectionRef.current?.(null);
|
|
112
|
+
onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => {
|
|
117
|
+
const distance = Math.hypot(
|
|
118
|
+
event.clientX - drag.originClientX,
|
|
119
|
+
event.clientY - drag.originClientY,
|
|
120
|
+
);
|
|
121
|
+
if (!drag.started && distance < 4) return;
|
|
122
|
+
setShowPopover(false);
|
|
123
|
+
setRangeSelectionRef.current?.(null);
|
|
124
|
+
setDraggedClip((previous) =>
|
|
125
|
+
previous
|
|
126
|
+
? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
|
|
127
|
+
: previous,
|
|
128
|
+
);
|
|
129
|
+
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleWindowPointerMove = (event: PointerEvent) => {
|
|
133
|
+
const resize = resizingClipRef.current;
|
|
134
|
+
if (resize) return handleResizePointerMove(event, resize);
|
|
135
|
+
const blocked = blockedClipRef.current;
|
|
136
|
+
if (blocked) return handleBlockedPointerMove(event, blocked);
|
|
137
|
+
const drag = draggedClipRef.current;
|
|
138
|
+
if (drag) handleDragPointerMove(event, drag);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const commitResizePointerUp = (resize: ResizingClipState) => {
|
|
142
|
+
resizingClipRef.current = null;
|
|
143
|
+
setResizingClip(null);
|
|
144
|
+
const groupSession = groupResizeRef.current;
|
|
145
|
+
groupResizeRef.current = null;
|
|
146
|
+
if (!resize.started) {
|
|
147
|
+
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
suppressClickRef.current = true;
|
|
151
|
+
clearSuppressedClick();
|
|
152
|
+
if (groupSession) {
|
|
153
|
+
commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const hasChanged =
|
|
157
|
+
resize.previewStart !== resize.element.start ||
|
|
158
|
+
resize.previewDuration !== resize.element.duration ||
|
|
159
|
+
resize.previewPlaybackStart !== resize.element.playbackStart;
|
|
160
|
+
if (!hasChanged) return;
|
|
161
|
+
|
|
162
|
+
const resizeKey = resize.element.key ?? resize.element.id;
|
|
163
|
+
const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]);
|
|
164
|
+
updateElement(resizeKey, {
|
|
165
|
+
start: resize.previewStart,
|
|
166
|
+
duration: resize.previewDuration,
|
|
167
|
+
playbackStart: resize.previewPlaybackStart,
|
|
168
|
+
});
|
|
169
|
+
Promise.resolve(
|
|
170
|
+
onResizeElementRef.current?.(resize.element, {
|
|
171
|
+
start: resize.previewStart,
|
|
172
|
+
duration: resize.previewDuration,
|
|
173
|
+
playbackStart: resize.previewPlaybackStart,
|
|
174
|
+
}),
|
|
175
|
+
).catch((error) => {
|
|
176
|
+
rollbackLatestTimelineOptimisticGesture(updateElement, revision, [
|
|
177
|
+
{
|
|
178
|
+
key: resizeKey,
|
|
179
|
+
updates: {
|
|
180
|
+
start: resize.element.start,
|
|
181
|
+
duration: resize.element.duration,
|
|
182
|
+
playbackStart: resize.element.playbackStart,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
console.error("[Timeline] Failed to persist clip resize", error);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const finishBlockedPointerUp = (blocked: BlockedClipState) => {
|
|
191
|
+
blockedClipRef.current = null;
|
|
192
|
+
if (blocked.started) clearSuppressedClick();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const commitDragPointerUp = (drag: DraggedClipState) => {
|
|
196
|
+
draggedClipRef.current = null;
|
|
197
|
+
setDraggedClip(null);
|
|
198
|
+
if (!drag.started) return;
|
|
199
|
+
suppressClickRef.current = true;
|
|
200
|
+
clearSuppressedClick();
|
|
201
|
+
commitDraggedClipMove(drag, {
|
|
202
|
+
elements: elementsRef.current,
|
|
203
|
+
trackOrder: trackOrderRef.current,
|
|
204
|
+
updateElement,
|
|
205
|
+
onMoveElement: onMoveElementRef.current,
|
|
206
|
+
onMoveElements: onMoveElementsRef.current,
|
|
207
|
+
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
|
208
|
+
readZIndex: readZIndexRef.current,
|
|
209
|
+
onStackingPatches: onStackingPatchesRef.current,
|
|
210
|
+
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handleWindowPointerUp = () => {
|
|
215
|
+
stopClipDragAutoScrollRef.current();
|
|
216
|
+
const resize = resizingClipRef.current;
|
|
217
|
+
if (resize) return commitResizePointerUp(resize);
|
|
218
|
+
const blocked = blockedClipRef.current;
|
|
219
|
+
if (blocked) return finishBlockedPointerUp(blocked);
|
|
220
|
+
const drag = draggedClipRef.current;
|
|
221
|
+
if (!drag) {
|
|
222
|
+
if (suppressClickRef.current) clearSuppressedClick();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
commitDragPointerUp(drag);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
|
229
|
+
const decision = resolveTimelineDragEscape({
|
|
230
|
+
key: event.key,
|
|
231
|
+
drag: draggedClipRef.current,
|
|
232
|
+
resize: resizingClipRef.current,
|
|
233
|
+
blocked: blockedClipRef.current,
|
|
234
|
+
});
|
|
235
|
+
if (!decision.cancel) return;
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
event.stopPropagation();
|
|
238
|
+
stopClipDragAutoScrollRef.current();
|
|
239
|
+
draggedClipRef.current = null;
|
|
240
|
+
setDraggedClip(null);
|
|
241
|
+
resizingClipRef.current = null;
|
|
242
|
+
setResizingClip(null);
|
|
243
|
+
const groupSession = groupResizeRef.current;
|
|
244
|
+
groupResizeRef.current = null;
|
|
245
|
+
if (groupSession) restoreGroupResizeMembers(groupSession);
|
|
246
|
+
blockedClipRef.current = null;
|
|
247
|
+
if (decision.suppressClick) suppressClickRef.current = true;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
251
|
+
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
252
|
+
window.addEventListener("pointercancel", handleWindowPointerUp);
|
|
253
|
+
window.addEventListener("keydown", handleWindowKeyDown, true);
|
|
254
|
+
return () => {
|
|
255
|
+
stopClipDragAutoScrollRef.current();
|
|
256
|
+
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
257
|
+
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
258
|
+
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
|
259
|
+
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -29,6 +29,8 @@ export interface TimelineClipDiamondsProps {
|
|
|
29
29
|
keyframesData: KeyframeCacheEntry;
|
|
30
30
|
clipWidthPx: number;
|
|
31
31
|
clipHeightPx: number;
|
|
32
|
+
/** Needed to compare the playhead to keyframes in output-frame time. */
|
|
33
|
+
clipDuration: number;
|
|
32
34
|
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
|
33
35
|
* the bottom half so they clear the strip at the top. */
|
|
34
36
|
beatsActive?: boolean;
|
|
@@ -70,7 +72,10 @@ export interface TimelineDiamondLaneProps extends Omit<
|
|
|
70
72
|
| "onContextMenuKeyframe"
|
|
71
73
|
| "onMoveKeyframe"
|
|
72
74
|
| "onSelectSegment"
|
|
75
|
+
| "clipDuration"
|
|
73
76
|
> {
|
|
77
|
+
/** Isolated lanes may omit timing; without it no playhead diamond is marked. */
|
|
78
|
+
clipDuration?: number;
|
|
74
79
|
groupAware?: boolean;
|
|
75
80
|
globalEase?: string;
|
|
76
81
|
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
TIMELINE_COMPOSITION_MIME,
|
|
6
6
|
} from "../../utils/timelineCompositionDrop";
|
|
7
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
-
import { resolveTimelineAssetDrop } from "./timelineLayout";
|
|
8
|
+
import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
|
|
9
9
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
10
10
|
|
|
11
11
|
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
@@ -13,7 +13,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
|
13
13
|
ppsRef: RefObject<number>;
|
|
14
14
|
durationRef: RefObject<number>;
|
|
15
15
|
trackOrderRef: RefObject<number[]>;
|
|
16
|
-
|
|
16
|
+
rowGeometryRef: RefObject<TimelineRowGeometry>;
|
|
17
17
|
contentOrigin: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -56,7 +56,7 @@ export function useTimelineAssetDrop({
|
|
|
56
56
|
ppsRef,
|
|
57
57
|
durationRef,
|
|
58
58
|
trackOrderRef,
|
|
59
|
-
|
|
59
|
+
rowGeometryRef,
|
|
60
60
|
contentOrigin,
|
|
61
61
|
onFileDrop,
|
|
62
62
|
onAssetDrop,
|
|
@@ -93,7 +93,7 @@ export function useTimelineAssetDrop({
|
|
|
93
93
|
pixelsPerSecond: ppsRef.current,
|
|
94
94
|
duration: durationRef.current,
|
|
95
95
|
clampStartToDuration: !usePointerStart,
|
|
96
|
-
rowHeights:
|
|
96
|
+
rowHeights: rowGeometryRef.current.rowHeights,
|
|
97
97
|
trackOrder: trackOrderRef.current,
|
|
98
98
|
},
|
|
99
99
|
clientX,
|
|
@@ -104,7 +104,7 @@ export function useTimelineAssetDrop({
|
|
|
104
104
|
track: pointer.track,
|
|
105
105
|
};
|
|
106
106
|
},
|
|
107
|
-
[scrollRef, ppsRef, durationRef, trackOrderRef,
|
|
107
|
+
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
const handleAssetDrop = useCallback(
|
|
@@ -669,6 +669,38 @@ describe("resolveTimelineAutoScroll", () => {
|
|
|
669
669
|
),
|
|
670
670
|
).toEqual({ x: 9, y: 6 });
|
|
671
671
|
});
|
|
672
|
+
|
|
673
|
+
it("uses the time-grid edge instead of the viewport edge when labels are sticky", () => {
|
|
674
|
+
expect(
|
|
675
|
+
resolveTimelineAutoScroll(
|
|
676
|
+
{
|
|
677
|
+
left: 100,
|
|
678
|
+
top: 100,
|
|
679
|
+
right: 700,
|
|
680
|
+
bottom: 400,
|
|
681
|
+
},
|
|
682
|
+
340,
|
|
683
|
+
250,
|
|
684
|
+
232,
|
|
685
|
+
),
|
|
686
|
+
).toEqual({ x: -10, y: 0 });
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("caps auto-scroll speed when the pointer moves inside the sticky labels", () => {
|
|
690
|
+
expect(
|
|
691
|
+
resolveTimelineAutoScroll(
|
|
692
|
+
{
|
|
693
|
+
left: 100,
|
|
694
|
+
top: 100,
|
|
695
|
+
right: 700,
|
|
696
|
+
bottom: 400,
|
|
697
|
+
},
|
|
698
|
+
120,
|
|
699
|
+
250,
|
|
700
|
+
232,
|
|
701
|
+
),
|
|
702
|
+
).toEqual({ x: -12, y: 0 });
|
|
703
|
+
});
|
|
672
704
|
});
|
|
673
705
|
|
|
674
706
|
describe("buildTimelineAgentPrompt", () => {
|
|
@@ -84,21 +84,24 @@ export function resolveTimelineAutoScroll(
|
|
|
84
84
|
bounds: TimelineAutoScrollBounds,
|
|
85
85
|
clientX: number,
|
|
86
86
|
clientY: number,
|
|
87
|
+
leftInset = 0,
|
|
87
88
|
): { x: number; y: number } {
|
|
88
89
|
const getAxisDelta = (start: number, end: number, pointer: number) => {
|
|
89
90
|
if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
|
|
90
|
-
const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
|
|
91
|
+
const proximity = Math.min(1, Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE));
|
|
91
92
|
return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
|
92
93
|
}
|
|
93
94
|
if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
|
|
94
|
-
const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
|
|
95
|
+
const proximity = Math.min(1, Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE));
|
|
95
96
|
return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
|
96
97
|
}
|
|
97
98
|
return 0;
|
|
98
99
|
};
|
|
99
100
|
|
|
101
|
+
const horizontalStart = Math.min(bounds.right, bounds.left + Math.max(0, leftInset));
|
|
102
|
+
|
|
100
103
|
return {
|
|
101
|
-
x: getAxisDelta(
|
|
104
|
+
x: getAxisDelta(horizontalStart, bounds.right, clientX),
|
|
102
105
|
y: getAxisDelta(bounds.top, bounds.bottom, clientY),
|
|
103
106
|
};
|
|
104
107
|
}
|
|
@@ -485,7 +488,41 @@ export function applyTimelineAutoScrollStep(
|
|
|
485
488
|
clientX: number,
|
|
486
489
|
clientY: number,
|
|
487
490
|
): boolean {
|
|
488
|
-
|
|
491
|
+
return applyTimelineAutoScrollDelta(
|
|
492
|
+
scroll,
|
|
493
|
+
resolveTimelineAutoScroll(
|
|
494
|
+
scroll.getBoundingClientRect(),
|
|
495
|
+
clientX,
|
|
496
|
+
clientY,
|
|
497
|
+
getTimelineAutoScrollLeftInset(scroll),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Apply one horizontal edge-scroll step while scrubbing the ruler/playhead. */
|
|
503
|
+
export function applyTimelineHorizontalAutoScrollStep(
|
|
504
|
+
scroll: HTMLElement,
|
|
505
|
+
clientX: number,
|
|
506
|
+
): boolean {
|
|
507
|
+
const bounds = scroll.getBoundingClientRect();
|
|
508
|
+
const delta = resolveTimelineAutoScroll(
|
|
509
|
+
bounds,
|
|
510
|
+
clientX,
|
|
511
|
+
bounds.top + (bounds.bottom - bounds.top) / 2,
|
|
512
|
+
getTimelineAutoScrollLeftInset(scroll),
|
|
513
|
+
);
|
|
514
|
+
return applyTimelineAutoScrollDelta(scroll, { x: delta.x, y: 0 });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function getTimelineAutoScrollLeftInset(scroll: HTMLElement): number {
|
|
518
|
+
const inset = Number(scroll.dataset.timelineAutoScrollLeftInset);
|
|
519
|
+
return Number.isFinite(inset) ? Math.max(0, inset) : 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function applyTimelineAutoScrollDelta(
|
|
523
|
+
scroll: HTMLElement,
|
|
524
|
+
delta: { x: number; y: number },
|
|
525
|
+
): boolean {
|
|
489
526
|
if (delta.x === 0 && delta.y === 0) return false;
|
|
490
527
|
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
491
528
|
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
|
@@ -510,7 +547,12 @@ export function resolveTimelineAutoScrollLoopAction(
|
|
|
510
547
|
rafActive: boolean,
|
|
511
548
|
): "start" | "stop" | "none" {
|
|
512
549
|
if (!scroll) return "none";
|
|
513
|
-
const delta = resolveTimelineAutoScroll(
|
|
550
|
+
const delta = resolveTimelineAutoScroll(
|
|
551
|
+
scroll.getBoundingClientRect(),
|
|
552
|
+
clientX,
|
|
553
|
+
clientY,
|
|
554
|
+
getTimelineAutoScrollLeftInset(scroll),
|
|
555
|
+
);
|
|
514
556
|
if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
|
|
515
557
|
return rafActive ? "none" : "start";
|
|
516
558
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { resolveTimelineFocusIdentity } from "./timelineFocusIdentity";
|
|
4
|
+
|
|
5
|
+
describe("timeline focus identity", () => {
|
|
6
|
+
const elements: TimelineElement[] = [
|
|
7
|
+
{ id: "clip/a", key: "stable:key", tag: "div", start: 0, duration: 1, track: 7.5 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
it("resolves a stable element id and fractional display row from model identity", () => {
|
|
11
|
+
expect(resolveTimelineFocusIdentity(elements, "stable:key")).toEqual({
|
|
12
|
+
elementId: "stable:key",
|
|
13
|
+
rowKey: 7.5,
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does not invent focus for a missing or cleared identity", () => {
|
|
18
|
+
expect(resolveTimelineFocusIdentity(elements, "missing")).toBeNull();
|
|
19
|
+
expect(resolveTimelineFocusIdentity(elements, null)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
3
|
+
|
|
4
|
+
export interface TimelineFocusIdentity {
|
|
5
|
+
readonly elementId: string;
|
|
6
|
+
readonly rowKey: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Resolve logical focus from model identity; mount state is deliberately irrelevant. */
|
|
10
|
+
export function resolveTimelineFocusIdentity(
|
|
11
|
+
elements: readonly TimelineElement[],
|
|
12
|
+
elementId: string | null,
|
|
13
|
+
): TimelineFocusIdentity | null {
|
|
14
|
+
if (!elementId) return null;
|
|
15
|
+
const element = getTimelineElementIndexes(elements).byKey.get(elementId);
|
|
16
|
+
if (!element) return null;
|
|
17
|
+
return {
|
|
18
|
+
elementId,
|
|
19
|
+
rowKey: element.track,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -6,6 +6,9 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
|
6
6
|
import type { TimelineTheme } from "./timelineTheme";
|
|
7
7
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
8
8
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
9
|
+
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
10
|
+
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
11
|
+
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
|
@@ -22,6 +25,12 @@ export interface TimelineLaneBaseProps {
|
|
|
22
25
|
theme: TimelineTheme;
|
|
23
26
|
displayTrackOrder: number[];
|
|
24
27
|
rowHeights: readonly number[];
|
|
28
|
+
rowGeometry: TimelineRowGeometry;
|
|
29
|
+
virtualRows: readonly TimelineVirtualRow[];
|
|
30
|
+
rowsVirtualized: boolean;
|
|
31
|
+
clipIndex: TimelineClipIndex;
|
|
32
|
+
renderTimeRange: TimelineTimeRange;
|
|
33
|
+
pinnedClipIdentities: ReadonlySet<string>;
|
|
25
34
|
trackOrder: number[];
|
|
26
35
|
tracks: [number, TimelineElement[]][];
|
|
27
36
|
trackStyles: Map<number, TrackVisualStyle>;
|
|
@@ -15,9 +15,50 @@ import {
|
|
|
15
15
|
getTimelineRowFromY,
|
|
16
16
|
getTimelineRowOffsets,
|
|
17
17
|
getTimelineCanvasHeight,
|
|
18
|
+
createTimelineRowGeometry,
|
|
19
|
+
getTimelineRowGeometry,
|
|
18
20
|
trackHeights,
|
|
19
21
|
resolveTimelineAssetDrop,
|
|
22
|
+
getTimelineBeatEntries,
|
|
20
23
|
} from "./timelineLayout";
|
|
24
|
+
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
|
25
|
+
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
26
|
+
|
|
27
|
+
describe("horizontal timeline window", () => {
|
|
28
|
+
it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => {
|
|
29
|
+
expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
|
|
30
|
+
{ start: 0, end: 7.25 },
|
|
31
|
+
);
|
|
32
|
+
expect(
|
|
33
|
+
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
|
|
34
|
+
).toEqual({ start: 15.75, end: 20 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("generates globally aligned ticks directly inside the bounded window", () => {
|
|
38
|
+
const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
|
|
39
|
+
const interval = getTimelineMajorTickInterval(10_000, 100);
|
|
40
|
+
expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
|
|
41
|
+
expect(
|
|
42
|
+
ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
|
|
43
|
+
).toBe(true);
|
|
44
|
+
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("slices beat records with original strength indexes and unions a pinned beat", () => {
|
|
48
|
+
expect(
|
|
49
|
+
getTimelineBeatEntries(
|
|
50
|
+
[0, 1, 2, 3],
|
|
51
|
+
[0.1, 0.2, 0.3, 0.4],
|
|
52
|
+
{ start: 1, end: 3 },
|
|
53
|
+
new Set([3]),
|
|
54
|
+
),
|
|
55
|
+
).toEqual([
|
|
56
|
+
{ index: 1, time: 1, strength: 0.2 },
|
|
57
|
+
{ index: 2, time: 2, strength: 0.3 },
|
|
58
|
+
{ index: 3, time: 3, strength: 0.4 },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
21
62
|
|
|
22
63
|
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
|
|
23
64
|
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
|
|
@@ -64,6 +105,24 @@ describe("variable timeline row geometry", () => {
|
|
|
64
105
|
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
|
|
65
106
|
);
|
|
66
107
|
});
|
|
108
|
+
|
|
109
|
+
it("reuses one immutable geometry snapshot for one height array", () => {
|
|
110
|
+
const heights = trackHeights(tracks, new Set(["b"]));
|
|
111
|
+
const first = getTimelineRowGeometry(heights);
|
|
112
|
+
expect(getTimelineRowGeometry(heights)).toBe(first);
|
|
113
|
+
expect(Object.isFrozen(first)).toBe(true);
|
|
114
|
+
expect(Object.isFrozen(first.rowOffsets)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("looks up row boundaries through the precomputed geometry", () => {
|
|
118
|
+
const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]);
|
|
119
|
+
expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry);
|
|
120
|
+
expect(geometry.getRowIndex(8)).toBe(1);
|
|
121
|
+
expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1);
|
|
122
|
+
expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2);
|
|
123
|
+
expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2);
|
|
124
|
+
expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD);
|
|
125
|
+
});
|
|
67
126
|
});
|
|
68
127
|
|
|
69
128
|
describe("collapsed timeline row geometry characterization", () => {
|