@hyperframes/studio 0.8.93 → 0.8.95
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-CkV9KK6A.js → hyperframes-player-DJJKab4_.js} +1 -1
- package/dist/assets/{index-V3fzEfOD.js → index-C1dXTZvs.js} +1 -1
- package/dist/assets/{index-CYk0NwSv.js → index-Cc4by8r7.js} +183 -183
- package/dist/assets/{index-BlLFipOk.js → index-DE1dn3QB.js} +1 -1
- package/dist/index.d.ts +63 -46
- package/dist/index.html +1 -1
- package/dist/index.js +196 -111
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +17 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
- package/src/components/editor/DomEditCropHandles.tsx +64 -62
- package/src/components/editor/DomEditOverlay.test.ts +39 -0
- package/src/components/editor/DomEditOverlay.tsx +6 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
- package/src/components/editor/SnapToolbar.tsx +1 -1
- package/src/components/editor/domEditOverlayGeometry.ts +2 -0
- package/src/components/editor/manualEditsSeekReapply.ts +5 -1
- package/src/components/nle/PreviewOverlays.tsx +3 -3
- package/src/hooks/gsapResizeIntercept.test.ts +40 -0
- package/src/hooks/gsapResizeIntercept.ts +8 -4
- package/src/index.ts +4 -0
- package/src/player/components/PlayerControls.tsx +2 -15
- package/src/player/components/ShortcutsPanel.test.tsx +33 -1
- package/src/player/components/ShortcutsPanel.tsx +26 -0
- package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
- package/src/player/components/Timeline.test.ts +3 -3
- package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
- package/src/player/components/TimelineCanvas.tsx +44 -19
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineDragDrop.test.tsx +14 -12
- package/src/player/components/timelineLayout.test.ts +87 -11
- package/src/player/components/timelineLayout.ts +28 -21
- package/src/player/components/useTimelineProviderState.tsx +5 -1
- package/src/player/components/useTimelineTrackLayout.ts +11 -2
- package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
- package/src/player/components/useTimelineVirtualRows.ts +2 -2
- package/src/utils/gsapSoftReload.test.ts +51 -2
- package/src/utils/gsapSoftReload.ts +24 -10
- package/src/utils/gsapUndoRestore.test.ts +49 -0
- package/src/utils/gsapUndoRestore.ts +15 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
10
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
11
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
12
|
+
|
|
13
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
installTimelineMountEnv();
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function mountCanvas(trackPadding?: TimelineProps["trackPadding"]) {
|
|
22
|
+
usePlayerStore.setState({
|
|
23
|
+
duration: 10,
|
|
24
|
+
currentTime: 0,
|
|
25
|
+
timelineReady: true,
|
|
26
|
+
gsapAnimations: new Map(),
|
|
27
|
+
elements: [{ id: "card", label: "Card", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
28
|
+
});
|
|
29
|
+
const host = document.createElement("div");
|
|
30
|
+
document.body.append(host);
|
|
31
|
+
const root = createRoot(host);
|
|
32
|
+
await act(async () => root.render(<Timeline trackPadding={trackPadding} />));
|
|
33
|
+
const canvas = Array.from(host.querySelectorAll<HTMLElement>("div.relative")).find(
|
|
34
|
+
(el) => el.style.height && el.style.width,
|
|
35
|
+
);
|
|
36
|
+
const height = canvas?.style.height;
|
|
37
|
+
const firstRowTop = host.querySelector<HTMLElement>('[data-timeline-row="0"]')?.style.top;
|
|
38
|
+
act(() => root.unmount());
|
|
39
|
+
return { height, firstRowTop };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Timeline trackPadding", () => {
|
|
43
|
+
it("keeps Studio's pads by default", async () => {
|
|
44
|
+
expect(await mountCanvas()).toEqual({
|
|
45
|
+
height: `${RULER_H + TRACKS_TOP_PAD + TRACK_H + TRACKS_BOTTOM_PAD}px`,
|
|
46
|
+
firstRowTop: `${RULER_H + TRACKS_TOP_PAD}px`,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("places the first row and sizes the canvas from the host's pads", async () => {
|
|
51
|
+
expect(await mountCanvas({ top: 0, bottom: TRACK_H })).toEqual({
|
|
52
|
+
height: `${RULER_H + 2 * TRACK_H}px`,
|
|
53
|
+
firstRowTop: `${RULER_H}px`,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// With no top pad, y = 96 is the middle of row 1; any reader still on the default pad sees row 0.
|
|
58
|
+
const ROW1_MID = RULER_H + TRACK_H + TRACK_H / 2;
|
|
59
|
+
const hostPads = { top: 0, bottom: TRACK_H };
|
|
60
|
+
|
|
61
|
+
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
62
|
+
usePlayerStore.setState({
|
|
63
|
+
duration: 10,
|
|
64
|
+
currentTime: 0,
|
|
65
|
+
timelineReady: true,
|
|
66
|
+
gsapAnimations: new Map(),
|
|
67
|
+
selectedElementId: null,
|
|
68
|
+
selectedElementIds: new Set(),
|
|
69
|
+
elements: [0, 1, 2].map((track) => ({
|
|
70
|
+
id: `c${track}`,
|
|
71
|
+
key: `c${track}`,
|
|
72
|
+
domId: `c${track}`,
|
|
73
|
+
tag: "div",
|
|
74
|
+
start: 0,
|
|
75
|
+
duration: 2,
|
|
76
|
+
track,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
const host = document.createElement("div");
|
|
80
|
+
document.body.append(host);
|
|
81
|
+
const root = createRoot(host);
|
|
82
|
+
await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
|
|
83
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
84
|
+
return { host, root, viewport };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
|
|
88
|
+
|
|
89
|
+
it("draws the gap strip, drop preview and insert line, and drops, on the host's rows", async () => {
|
|
90
|
+
const onAssetDrop = vi.fn();
|
|
91
|
+
const { host, root, viewport } = await mountThreeTracks({ onAssetDrop });
|
|
92
|
+
act(() => usePlayerStore.getState().setSelectedElementId("c1"));
|
|
93
|
+
expect(top(host.querySelector('[style*="--timeline-accent-faint"]'))).toBe(
|
|
94
|
+
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const drag = (type: string, clientY: number) => {
|
|
98
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
99
|
+
Object.defineProperties(event, {
|
|
100
|
+
clientX: { value: 400 },
|
|
101
|
+
clientY: { value: clientY },
|
|
102
|
+
dataTransfer: {
|
|
103
|
+
value: {
|
|
104
|
+
types: [TIMELINE_ASSET_MIME],
|
|
105
|
+
files: [],
|
|
106
|
+
dropEffect: "none",
|
|
107
|
+
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
act(() => void viewport.dispatchEvent(event));
|
|
112
|
+
};
|
|
113
|
+
drag("dragover", ROW1_MID);
|
|
114
|
+
expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
|
|
115
|
+
`${RULER_H + TRACK_H + CLIP_Y}px`,
|
|
116
|
+
);
|
|
117
|
+
drag("dragover", RULER_H / 2);
|
|
118
|
+
expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
|
|
119
|
+
`${RULER_H - 0.5}px`,
|
|
120
|
+
);
|
|
121
|
+
drag("drop", ROW1_MID);
|
|
122
|
+
expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 1 }));
|
|
123
|
+
act(() => root.unmount());
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("drags a clip onto the host's rows and opens a new track in the bottom pad", async () => {
|
|
127
|
+
const { host, root } = await mountThreeTracks({
|
|
128
|
+
onMoveElement: vi.fn(),
|
|
129
|
+
onResizeElement: vi.fn(),
|
|
130
|
+
});
|
|
131
|
+
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
132
|
+
act(
|
|
133
|
+
() =>
|
|
134
|
+
void target.dispatchEvent(
|
|
135
|
+
new PointerEvent(type, {
|
|
136
|
+
bubbles: true,
|
|
137
|
+
button: 0,
|
|
138
|
+
pointerId: 1,
|
|
139
|
+
clientX: 400,
|
|
140
|
+
clientY,
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
|
|
145
|
+
pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
|
|
146
|
+
pointer(window, "pointermove", ROW1_MID);
|
|
147
|
+
const ghost = Array.from(
|
|
148
|
+
host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
|
|
149
|
+
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
|
|
150
|
+
expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
|
|
151
|
+
pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
|
|
152
|
+
expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
|
|
153
|
+
`${RULER_H + 3 * TRACK_H - 0.5}px`,
|
|
154
|
+
);
|
|
155
|
+
act(() => root.unmount());
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -2,16 +2,7 @@ import { memo } from "react";
|
|
|
2
2
|
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
4
|
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
|
|
5
|
-
import {
|
|
6
|
-
RULER_H,
|
|
7
|
-
CLIP_Y,
|
|
8
|
-
TRACKS_TOP_PAD,
|
|
9
|
-
TRACKS_BOTTOM_PAD,
|
|
10
|
-
TRACK_H,
|
|
11
|
-
PLAYHEAD_HEAD_W,
|
|
12
|
-
getTimelineRowTop,
|
|
13
|
-
getTimelineRowHeight,
|
|
14
|
-
} from "./timelineLayout";
|
|
5
|
+
import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
|
|
15
6
|
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
|
|
16
7
|
import { TimelineLanes } from "./TimelineLanes";
|
|
17
8
|
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
@@ -33,6 +24,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
33
24
|
const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
|
|
34
25
|
const insertLineRow =
|
|
35
26
|
(draggedClip?.started ? draggedClip.insertRow : null) ?? props.dropPreview?.insertRow ?? null;
|
|
27
|
+
const ghostLaneActive =
|
|
28
|
+
(insertLineRow ?? -1) >= props.trackOrder.length ||
|
|
29
|
+
(props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
|
|
30
|
+
// A host bottom pad shorter than a track leaves no lane to draw.
|
|
31
|
+
const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
|
|
32
|
+
// Past the last committed track: a drag's new bottom-track preview row is the lane itself.
|
|
33
|
+
const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
|
|
36
34
|
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
|
37
35
|
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
|
38
36
|
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
|
@@ -49,8 +47,11 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
49
47
|
<TimelineRulerPart />
|
|
50
48
|
|
|
51
49
|
{/* Breathing room between the sticky ruler and the first track lane — the
|
|
52
|
-
top half of the CapCut-style padding (see
|
|
53
|
-
<div
|
|
50
|
+
top half of the CapCut-style padding (see TimelineRowGeometry.padding). */}
|
|
51
|
+
<div
|
|
52
|
+
aria-hidden="true"
|
|
53
|
+
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.top }}
|
|
54
|
+
/>
|
|
54
55
|
|
|
55
56
|
<TimelineLanes
|
|
56
57
|
{...props}
|
|
@@ -67,10 +68,34 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
67
68
|
onRazorSplitAll={props.onRazorSplitAll}
|
|
68
69
|
/>
|
|
69
70
|
|
|
70
|
-
{/* Breathing room below the last track lane (
|
|
71
|
+
{/* Breathing room below the last track lane (one track by default) — a real
|
|
71
72
|
scrollable surface, so a clip can be dragged into the void to create a
|
|
72
|
-
new bottom track
|
|
73
|
-
<div
|
|
73
|
+
new bottom track (see TimelineRowGeometry.padding / canvasHeight). */}
|
|
74
|
+
<div
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
{showGhostLane && (
|
|
80
|
+
<div
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
data-testid="timeline-ghost-lane"
|
|
83
|
+
data-active={ghostLaneActive || undefined}
|
|
84
|
+
className={`pointer-events-none absolute flex items-center justify-center rounded-lg border border-dashed text-[11px] transition-colors duration-150 ${
|
|
85
|
+
ghostLaneActive
|
|
86
|
+
? "border-studio-accent/60 bg-studio-accent/6 text-studio-accent"
|
|
87
|
+
: "border-neutral-700/50 text-neutral-500"
|
|
88
|
+
}`}
|
|
89
|
+
style={{
|
|
90
|
+
top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
|
|
91
|
+
left: props.contentOrigin,
|
|
92
|
+
width: props.trackContentWidth,
|
|
93
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{props.acceptsMediaDrop && "Drop media here"}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
74
99
|
|
|
75
100
|
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
|
|
76
101
|
menu row would collapse; a quiet tint for every gap on the selected
|
|
@@ -91,7 +116,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
91
116
|
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
|
92
117
|
className="pointer-events-none absolute"
|
|
93
118
|
style={{
|
|
94
|
-
top:
|
|
119
|
+
top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
|
|
95
120
|
left: props.contentOrigin + gap.start * props.pps,
|
|
96
121
|
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
|
97
122
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -109,7 +134,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
109
134
|
<div
|
|
110
135
|
className="absolute pointer-events-none"
|
|
111
136
|
style={{
|
|
112
|
-
top:
|
|
137
|
+
top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
|
|
113
138
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
|
114
139
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
|
115
140
|
height: draggedClipHeight,
|
|
@@ -129,7 +154,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
129
154
|
data-testid="timeline-drop-preview"
|
|
130
155
|
className="absolute pointer-events-none"
|
|
131
156
|
style={{
|
|
132
|
-
top:
|
|
157
|
+
top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
|
|
133
158
|
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
134
159
|
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
135
160
|
height: TRACK_H - CLIP_Y * 2,
|
|
@@ -148,7 +173,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
148
173
|
data-testid="timeline-insert-line"
|
|
149
174
|
className="absolute pointer-events-none"
|
|
150
175
|
style={{
|
|
151
|
-
top:
|
|
176
|
+
top: props.rowGeometry.getRowTop(insertLineRow) - 0.5,
|
|
152
177
|
left: props.contentOrigin,
|
|
153
178
|
width: props.trackContentWidth,
|
|
154
179
|
height: 1,
|
|
@@ -51,6 +51,7 @@ export type TimelineCanvasState = Omit<
|
|
|
51
51
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
52
52
|
laneGapStrips: TimelineLaneGapStrips[];
|
|
53
53
|
dropPreview: TimelineDropPlacement | null;
|
|
54
|
+
acceptsMediaDrop: boolean;
|
|
54
55
|
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
55
56
|
setResizingClip: (value: ResizingClipState | null) => void;
|
|
56
57
|
setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
|
|
4
4
|
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import type { TimelineTheme } from "./timelineTheme";
|
|
6
|
+
import type { TimelineTrackPadding } from "./timelineLayout";
|
|
6
7
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
7
8
|
import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
|
|
8
9
|
|
|
@@ -35,4 +36,5 @@ export interface TimelineProps
|
|
|
35
36
|
theme?: Partial<TimelineTheme>;
|
|
36
37
|
showAudioEffects?: boolean;
|
|
37
38
|
showKeyframes?: boolean;
|
|
39
|
+
trackPadding?: TimelineTrackPadding;
|
|
38
40
|
}
|
|
@@ -12,6 +12,8 @@ import { configureTimelineTestViewport } from "./timelineTestViewport";
|
|
|
12
12
|
|
|
13
13
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
14
|
|
|
15
|
+
const ROW0_MID_Y = getTimelineRowTop(0) + TRACK_H / 2;
|
|
16
|
+
|
|
15
17
|
interface DropTransfer {
|
|
16
18
|
types: string[];
|
|
17
19
|
files: File[];
|
|
@@ -124,7 +126,7 @@ describe("useTimelineAssetDrop", () => {
|
|
|
124
126
|
const parent = document.createElement("div");
|
|
125
127
|
const child = document.createElement("div");
|
|
126
128
|
parent.append(child);
|
|
127
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
129
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
128
130
|
act(() =>
|
|
129
131
|
view.api.handleAssetDragLeave({
|
|
130
132
|
relatedTarget: child,
|
|
@@ -160,8 +162,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
160
162
|
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
161
163
|
|
|
162
164
|
act(() => {
|
|
163
|
-
view.api.handleAssetDragOver(dragEvent(transfer, 80,
|
|
164
|
-
view.api.handleAssetDrop(dragEvent(transfer, 80,
|
|
165
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 80, ROW0_MID_Y));
|
|
166
|
+
view.api.handleAssetDrop(dragEvent(transfer, 80, ROW0_MID_Y));
|
|
165
167
|
});
|
|
166
168
|
|
|
167
169
|
// pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
|
|
@@ -175,12 +177,12 @@ describe("useTimelineAssetDrop", () => {
|
|
|
175
177
|
const view = renderHarness(onAssetDrop, 1);
|
|
176
178
|
const transfer = assetTransfer("not-json");
|
|
177
179
|
|
|
178
|
-
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400,
|
|
180
|
+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y)));
|
|
179
181
|
expect(view.api.isDragOver).toBe(true);
|
|
180
182
|
view.rerender(2);
|
|
181
183
|
expect(view.api.isDragOver).toBe(false);
|
|
182
184
|
|
|
183
|
-
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400,
|
|
185
|
+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y)));
|
|
184
186
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
185
187
|
act(() => view.root.unmount());
|
|
186
188
|
});
|
|
@@ -202,8 +204,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
202
204
|
};
|
|
203
205
|
|
|
204
206
|
act(() => {
|
|
205
|
-
view.api.handleAssetDragOver(dragEvent(transfer, 400,
|
|
206
|
-
view.api.handleAssetDrop(dragEvent(transfer, 400,
|
|
207
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y));
|
|
208
|
+
view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y));
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
@@ -214,7 +216,7 @@ describe("useTimelineAssetDrop", () => {
|
|
|
214
216
|
|
|
215
217
|
it("clears an escaped drag after StrictMode effect replay", () => {
|
|
216
218
|
const view = renderHarness(vi.fn(), 1, { strict: true });
|
|
217
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
219
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
218
220
|
expect(view.api.isDragOver).toBe(true);
|
|
219
221
|
|
|
220
222
|
act(() => window.dispatchEvent(new Event("dragend")));
|
|
@@ -227,11 +229,11 @@ describe("useTimelineAssetDrop", () => {
|
|
|
227
229
|
const view = renderHarness(onAssetDrop);
|
|
228
230
|
const payload = JSON.stringify({ path: "assets/a.png" });
|
|
229
231
|
|
|
230
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400,
|
|
232
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
|
|
231
233
|
const preview = view.api.dropPreview;
|
|
232
234
|
expect(preview).toEqual({ start: 10, track: 0 });
|
|
233
235
|
|
|
234
|
-
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400,
|
|
236
|
+
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
|
|
235
237
|
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
|
236
238
|
expect(view.api.dropPreview).toBeNull();
|
|
237
239
|
act(() => view.root.unmount());
|
|
@@ -239,8 +241,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
239
241
|
|
|
240
242
|
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
|
241
243
|
const view = renderHarness(vi.fn());
|
|
242
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
243
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800,
|
|
244
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
245
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, ROW0_MID_Y)));
|
|
244
246
|
expect(view.api.dropPreview?.start).toBe(20);
|
|
245
247
|
|
|
246
248
|
act(() =>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getTimelineBeatEntries,
|
|
20
20
|
} from "./timelineLayout";
|
|
21
21
|
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
|
22
|
+
import { resolveInsertRow } from "./timelineCollision";
|
|
22
23
|
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
23
24
|
|
|
24
25
|
describe("horizontal timeline window", () => {
|
|
@@ -124,27 +125,27 @@ describe("variable timeline row geometry", () => {
|
|
|
124
125
|
|
|
125
126
|
describe("collapsed timeline row geometry characterization", () => {
|
|
126
127
|
it.each([
|
|
127
|
-
[0,
|
|
128
|
-
[1,
|
|
129
|
-
[4,
|
|
128
|
+
[0, 32],
|
|
129
|
+
[1, 80],
|
|
130
|
+
[4, 224],
|
|
130
131
|
])("keeps row %i at content y=%i", (row, expectedTop) => {
|
|
131
132
|
expect(getTimelineRowTop(row)).toBe(expectedTop);
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it.each([
|
|
135
|
-
[
|
|
136
|
-
[
|
|
137
|
-
[
|
|
138
|
-
[
|
|
136
|
+
[32, 0],
|
|
137
|
+
[44, 0.25],
|
|
138
|
+
[104, 1.5],
|
|
139
|
+
[248, 4.5],
|
|
139
140
|
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
|
|
140
141
|
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
|
|
141
142
|
});
|
|
142
143
|
|
|
143
144
|
it.each([
|
|
144
|
-
[0,
|
|
145
|
-
[1,
|
|
146
|
-
[3,
|
|
147
|
-
[5,
|
|
145
|
+
[0, 80],
|
|
146
|
+
[1, 128],
|
|
147
|
+
[3, 224],
|
|
148
|
+
[5, 320],
|
|
148
149
|
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
|
|
149
150
|
expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
|
|
150
151
|
});
|
|
@@ -247,6 +248,81 @@ describe("track-area breathing pad y-math", () => {
|
|
|
247
248
|
});
|
|
248
249
|
});
|
|
249
250
|
|
|
251
|
+
describe("host track padding", () => {
|
|
252
|
+
const geometry = createTimelineRowGeometry([0, 1, 2], baseRows(3), { top: 0, bottom: TRACK_H });
|
|
253
|
+
// Row 1 with no top pad, but still row 0 if any reader keeps the default pad.
|
|
254
|
+
const yInRow1 = RULER_H + TRACK_H + 10;
|
|
255
|
+
|
|
256
|
+
it("moves the first row up and sizes the canvas from the host pads", () => {
|
|
257
|
+
expect(geometry.getRowTop(0)).toBe(RULER_H);
|
|
258
|
+
expect(getTimelineRowTop(1, geometry.rowHeights)).toBe(RULER_H + TRACK_H);
|
|
259
|
+
expect(getTimelineCanvasHeight(geometry.rowHeights)).toBe(RULER_H + 4 * TRACK_H);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("maps a drag pointer y to the row under it", () => {
|
|
263
|
+
expect(Math.floor(geometry.getRowFromY(yInRow1))).toBe(1);
|
|
264
|
+
expect(Math.floor(getTimelineRowFromY(yInRow1, geometry.rowHeights))).toBe(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("drops onto the row under the pointer, and a new track in the bottom pad", () => {
|
|
268
|
+
const drop = {
|
|
269
|
+
rectLeft: 0,
|
|
270
|
+
rectTop: 0,
|
|
271
|
+
scrollLeft: 0,
|
|
272
|
+
scrollTop: 0,
|
|
273
|
+
contentOrigin: GUTTER,
|
|
274
|
+
pixelsPerSecond: 100,
|
|
275
|
+
rowHeights: geometry.rowHeights,
|
|
276
|
+
trackOrder: [0, 1, 2],
|
|
277
|
+
};
|
|
278
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, yInRow1).track).toBe(1);
|
|
279
|
+
const inBottomPad = RULER_H + 3 * TRACK_H + TRACK_H / 2;
|
|
280
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, inBottomPad).track).toBe(3);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("clamps a negative pad to 0 and replaces a non-finite one with the default", () => {
|
|
284
|
+
const odd = createTimelineRowGeometry([0], baseRows(1), { top: -20, bottom: Number.NaN });
|
|
285
|
+
expect(odd.padding).toEqual({ top: 0, bottom: TRACKS_BOTTOM_PAD });
|
|
286
|
+
expect(odd.getRowTop(0)).toBe(RULER_H);
|
|
287
|
+
expect(odd.canvasHeight).toBe(RULER_H + TRACK_H + TRACKS_BOTTOM_PAD);
|
|
288
|
+
const infinite = createTimelineRowGeometry([0], baseRows(1), { top: Infinity });
|
|
289
|
+
expect(infinite.padding.top).toBe(TRACKS_TOP_PAD);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("keeps the default pads when a host passes none", () => {
|
|
293
|
+
const defaults = createTimelineRowGeometry([0], baseRows(1));
|
|
294
|
+
expect(defaults.padding).toEqual({ top: TRACKS_TOP_PAD, bottom: TRACKS_BOTTOM_PAD });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("default track pads", () => {
|
|
299
|
+
const rows = [TRACK_H, TRACK_H, TRACK_H];
|
|
300
|
+
const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
|
|
301
|
+
|
|
302
|
+
it("sits the first row 8px under the ruler and leaves one track below the last", () => {
|
|
303
|
+
expect(getTimelineRowTop(0)).toBe(RULER_H + 8);
|
|
304
|
+
expect(getTimelineCanvasHeight(rows)).toBe(lastLaneBottom + TRACK_H);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("opens a new track from anywhere in the bottom lane", () => {
|
|
308
|
+
const drop = {
|
|
309
|
+
rectLeft: 0,
|
|
310
|
+
rectTop: 0,
|
|
311
|
+
scrollLeft: 0,
|
|
312
|
+
scrollTop: 0,
|
|
313
|
+
contentOrigin: GUTTER,
|
|
314
|
+
pixelsPerSecond: 100,
|
|
315
|
+
rowHeights: rows,
|
|
316
|
+
trackOrder: [0, 1, 2],
|
|
317
|
+
};
|
|
318
|
+
for (const y of [lastLaneBottom, getTimelineCanvasHeight(rows) - 1]) {
|
|
319
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, y).track).toBe(3);
|
|
320
|
+
expect(resolveInsertRow(getTimelineRowFromY(y, rows), rows.length)).toBe(3);
|
|
321
|
+
}
|
|
322
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, lastLaneBottom - 1).track).toBe(2);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
250
326
|
describe("getTimelineScrubTime", () => {
|
|
251
327
|
const at = (clientX: number, duration = 10) =>
|
|
252
328
|
getTimelineScrubTime({
|
|
@@ -55,17 +55,20 @@ export function getTimelineLaneTop(laneIndex: number): number {
|
|
|
55
55
|
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* offset; a track row's top is always ruler + top pad + cumulative row heights.
|
|
58
|
+
* Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
|
|
59
|
+
* it per Timeline; rows always read TimelineRowGeometry.padding, never these.
|
|
61
60
|
*
|
|
62
|
-
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
|
63
|
-
*
|
|
64
|
-
* - TRACKS_BOTTOM_PAD: empty
|
|
65
|
-
*
|
|
61
|
+
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
|
|
62
|
+
* just enough that the first clip isn't jammed under the ruler.
|
|
63
|
+
* - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
|
|
64
|
+
* dropping a clip or file there creates a new bottom track.
|
|
66
65
|
*/
|
|
67
|
-
export const TRACKS_TOP_PAD =
|
|
68
|
-
export const TRACKS_BOTTOM_PAD =
|
|
66
|
+
export const TRACKS_TOP_PAD = 8;
|
|
67
|
+
export const TRACKS_BOTTOM_PAD = TRACK_H;
|
|
68
|
+
export interface TimelineTrackPadding {
|
|
69
|
+
top?: number;
|
|
70
|
+
bottom?: number;
|
|
71
|
+
}
|
|
69
72
|
/**
|
|
70
73
|
* Breathing room LEFT of t=0 (CapCut-style), inside the scroll content — the
|
|
71
74
|
* horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
|
|
@@ -115,12 +118,17 @@ function validRowHeight(height: number | undefined): number {
|
|
|
115
118
|
return height;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
function validPad(pad: number | undefined, fallback: number): number {
|
|
122
|
+
return pad !== undefined && Number.isFinite(pad) ? Math.max(0, pad) : fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
export interface TimelineRowGeometry {
|
|
119
126
|
readonly rowKeys: readonly number[];
|
|
120
127
|
readonly rowHeights: readonly number[];
|
|
121
128
|
/** Cumulative row boundaries, including the final bottom boundary. */
|
|
122
129
|
readonly rowOffsets: readonly number[];
|
|
123
130
|
readonly rowsHeight: number;
|
|
131
|
+
readonly padding: Readonly<Required<TimelineTrackPadding>>;
|
|
124
132
|
readonly canvasHeight: number;
|
|
125
133
|
getRowIndex(rowKey: number): number;
|
|
126
134
|
getRowHeight(row: number): number;
|
|
@@ -141,7 +149,10 @@ const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]);
|
|
|
141
149
|
export function createTimelineRowGeometry(
|
|
142
150
|
rowKeys: readonly number[],
|
|
143
151
|
rowHeights: readonly number[],
|
|
152
|
+
padding: TimelineTrackPadding = {},
|
|
144
153
|
): TimelineRowGeometry {
|
|
154
|
+
const topPad = validPad(padding.top, TRACKS_TOP_PAD);
|
|
155
|
+
const bottomPad = validPad(padding.bottom, TRACKS_BOTTOM_PAD);
|
|
145
156
|
const heights = Object.freeze(rowHeights.map(validRowHeight));
|
|
146
157
|
const keys = Object.freeze(
|
|
147
158
|
heights.map((_, row) => {
|
|
@@ -165,7 +176,7 @@ export function createTimelineRowGeometry(
|
|
|
165
176
|
return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
|
|
166
177
|
};
|
|
167
178
|
const getRowFromY = (contentY: number) => {
|
|
168
|
-
const y = contentY - RULER_H -
|
|
179
|
+
const y = contentY - RULER_H - topPad;
|
|
169
180
|
if (heights.length === 0) return y / TRACK_H;
|
|
170
181
|
if (y < 0) return y / getRowHeight(0);
|
|
171
182
|
const rowsHeight = offsets[heights.length] ?? 0;
|
|
@@ -188,10 +199,11 @@ export function createTimelineRowGeometry(
|
|
|
188
199
|
rowHeights: heights,
|
|
189
200
|
rowOffsets: offsets,
|
|
190
201
|
rowsHeight: offsets.at(-1) ?? 0,
|
|
191
|
-
|
|
202
|
+
padding: Object.freeze({ top: topPad, bottom: bottomPad }),
|
|
203
|
+
canvasHeight: RULER_H + topPad + (offsets.at(-1) ?? 0) + bottomPad,
|
|
192
204
|
getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
|
|
193
205
|
getRowHeight,
|
|
194
|
-
getRowTop: (row) => RULER_H +
|
|
206
|
+
getRowTop: (row) => RULER_H + topPad + getRowOffset(row),
|
|
195
207
|
getRowFromY,
|
|
196
208
|
getRowPositionFromY: (contentY) => {
|
|
197
209
|
const rowFloat = getRowFromY(contentY);
|
|
@@ -204,7 +216,7 @@ export function createTimelineRowGeometry(
|
|
|
204
216
|
return frozenGeometry;
|
|
205
217
|
}
|
|
206
218
|
|
|
207
|
-
/** Compatibility accessor
|
|
219
|
+
/** Compatibility accessor: a geometry's own rowHeights resolves back to it, pads included. */
|
|
208
220
|
export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry {
|
|
209
221
|
const cached = rowGeometryCache.get(rowHeights);
|
|
210
222
|
if (cached) return cached;
|
|
@@ -228,10 +240,6 @@ export function getTimelineRowHeight(
|
|
|
228
240
|
return validRowHeight(rowHeights[row]);
|
|
229
241
|
}
|
|
230
242
|
|
|
231
|
-
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
|
|
232
|
-
return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
243
|
/**
|
|
236
244
|
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
|
237
245
|
* displayed lane). The single source of truth for row->y: the ruler height plus
|
|
@@ -243,7 +251,7 @@ export function getTimelineRowTop(
|
|
|
243
251
|
row: number,
|
|
244
252
|
rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
|
|
245
253
|
): number {
|
|
246
|
-
return
|
|
254
|
+
return getTimelineRowGeometry(rowHeights).getRowTop(row);
|
|
247
255
|
}
|
|
248
256
|
|
|
249
257
|
/**
|
|
@@ -484,9 +492,8 @@ export function getTimelinePlaybackFollowScrollLeft(input: {
|
|
|
484
492
|
}
|
|
485
493
|
|
|
486
494
|
export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
|
|
487
|
-
// RULER_H + top pad + lanes + bottom pad
|
|
488
|
-
//
|
|
489
|
-
// below the last lane is real scrollable surface, not a hidden buffer.
|
|
495
|
+
// RULER_H + top pad + lanes + bottom pad, read from the geometry's padding.
|
|
496
|
+
// The drag-into-void space below the last lane is real scrollable surface.
|
|
490
497
|
return getTimelineRowGeometry(rowHeights).canvasHeight;
|
|
491
498
|
}
|
|
492
499
|
|
|
@@ -65,7 +65,8 @@ export function useTimelineProviderState({
|
|
|
65
65
|
theme: themeOverrides,
|
|
66
66
|
showAudioEffects = true,
|
|
67
67
|
showKeyframes = true,
|
|
68
|
-
|
|
68
|
+
trackPadding,
|
|
69
|
+
sessionEpoch: sessionEpochProp,
|
|
69
70
|
previewIframeRef,
|
|
70
71
|
onZIndexReorder,
|
|
71
72
|
}: TimelineProps = {}): TimelineContextValue {
|
|
@@ -107,6 +108,7 @@ export function useTimelineProviderState({
|
|
|
107
108
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
108
109
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
109
110
|
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
111
|
+
const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
|
|
110
112
|
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
|
|
111
113
|
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
112
114
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
@@ -143,6 +145,7 @@ export function useTimelineProviderState({
|
|
|
143
145
|
gsapAnimations,
|
|
144
146
|
selectedElementId,
|
|
145
147
|
selectedElementIds,
|
|
148
|
+
trackPadding,
|
|
146
149
|
);
|
|
147
150
|
const timelineElementsRef = useRef(timelineElements);
|
|
148
151
|
timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
|
|
@@ -431,6 +434,7 @@ export function useTimelineProviderState({
|
|
|
431
434
|
marqueeRect,
|
|
432
435
|
laneGapStrips,
|
|
433
436
|
dropPreview: assetDrop.dropPreview,
|
|
437
|
+
acceptsMediaDrop: !!(onFileDrop || onAssetDrop),
|
|
434
438
|
theme,
|
|
435
439
|
showAudioEffects,
|
|
436
440
|
displayTrackOrder: displayLayout.displayTrackOrder,
|