@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10
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/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +132 -41
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -4,11 +4,18 @@ import {
|
|
|
4
4
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
5
|
getTimelineToggleTitle,
|
|
6
6
|
} from "../../utils/timelineDiscovery";
|
|
7
|
-
import { formatTime } from "../lib/time";
|
|
7
|
+
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
|
8
8
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
9
9
|
|
|
10
10
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
11
11
|
const SEEK_EDGE_SNAP_PX = 8;
|
|
12
|
+
type TimeDisplayMode = "time" | "frame";
|
|
13
|
+
const SHORTCUT_HINTS = [
|
|
14
|
+
{ key: "J", label: "Play backward" },
|
|
15
|
+
{ key: "K", label: "Stop playback" },
|
|
16
|
+
{ key: "L", label: "Play forward" },
|
|
17
|
+
{ key: "←/→", label: "Step one frame backward or forward" },
|
|
18
|
+
] as const;
|
|
12
19
|
|
|
13
20
|
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
|
|
14
21
|
if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
|
|
@@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
38
45
|
const duration = usePlayerStore((s) => s.duration);
|
|
39
46
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
40
47
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
48
|
+
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
|
|
41
49
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
50
|
+
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
|
|
42
51
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
52
|
+
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
|
|
53
|
+
const [jumpFrame, setJumpFrame] = useState("");
|
|
43
54
|
|
|
44
55
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
45
56
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
49
60
|
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
|
50
61
|
const isDraggingRef = useRef(false);
|
|
51
62
|
const currentTimeRef = useRef(0);
|
|
63
|
+
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
64
|
+
timeDisplayModeRef.current = timeDisplayMode;
|
|
52
65
|
|
|
53
66
|
const durationRef = useRef(duration);
|
|
54
67
|
durationRef.current = duration;
|
|
@@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
59
72
|
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
|
|
60
73
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
61
74
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
62
|
-
if (timeDisplayRef.current)
|
|
75
|
+
if (timeDisplayRef.current) {
|
|
76
|
+
timeDisplayRef.current.textContent =
|
|
77
|
+
timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
|
|
78
|
+
}
|
|
63
79
|
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
|
64
80
|
};
|
|
65
81
|
const unsub = liveTime.subscribe(updateProgress);
|
|
@@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
82
98
|
};
|
|
83
99
|
});
|
|
84
100
|
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!timeDisplayRef.current) return;
|
|
103
|
+
const t = currentTimeRef.current;
|
|
104
|
+
timeDisplayRef.current.textContent =
|
|
105
|
+
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
|
|
106
|
+
}, [duration, timeDisplayMode]);
|
|
107
|
+
|
|
85
108
|
useEffect(() => {
|
|
86
109
|
if (!showSpeedMenu) return;
|
|
87
110
|
const handleMouseDown = (e: MouseEvent) => {
|
|
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
190
213
|
const handleKeyDown = useCallback(
|
|
191
214
|
(e: React.KeyboardEvent) => {
|
|
192
215
|
if (!timelineReady || duration <= 0) return;
|
|
193
|
-
const step = e.shiftKey ?
|
|
216
|
+
const step = e.shiftKey ? 10 : 1;
|
|
194
217
|
if (e.key === "ArrowLeft") {
|
|
195
218
|
e.preventDefault();
|
|
196
|
-
onSeek(Math.max(0, currentTimeRef.current - step));
|
|
219
|
+
onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
|
|
197
220
|
} else if (e.key === "ArrowRight") {
|
|
198
221
|
e.preventDefault();
|
|
199
|
-
onSeek(Math.min(duration, currentTimeRef.current + step));
|
|
222
|
+
onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
|
|
200
223
|
}
|
|
201
224
|
},
|
|
202
225
|
[timelineReady, duration, onSeek],
|
|
203
226
|
);
|
|
204
227
|
|
|
228
|
+
const commitJumpFrame = useCallback(() => {
|
|
229
|
+
const frame = Number.parseInt(jumpFrame, 10);
|
|
230
|
+
if (!Number.isFinite(frame) || duration <= 0) return;
|
|
231
|
+
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
|
232
|
+
}, [duration, jumpFrame, onSeek]);
|
|
233
|
+
|
|
234
|
+
const handleJumpSubmit = useCallback(
|
|
235
|
+
(e: React.FormEvent) => {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
commitJumpFrame();
|
|
238
|
+
},
|
|
239
|
+
[commitJumpFrame],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const handleJumpKeyDown = useCallback(
|
|
243
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
244
|
+
if (e.key !== "Enter") return;
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
commitJumpFrame();
|
|
247
|
+
},
|
|
248
|
+
[commitJumpFrame],
|
|
249
|
+
);
|
|
250
|
+
|
|
205
251
|
return (
|
|
206
252
|
<div
|
|
207
|
-
className="px-4 py-2 flex items-center gap-
|
|
253
|
+
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
208
254
|
style={{
|
|
209
255
|
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
210
256
|
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
|
@@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
236
282
|
|
|
237
283
|
{/* Time display */}
|
|
238
284
|
<span
|
|
239
|
-
className="font-mono text-[11px] tabular-nums flex-shrink-0
|
|
285
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
|
|
240
286
|
style={{ color: "#A1A1AA" }}
|
|
241
287
|
>
|
|
242
288
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
243
|
-
|
|
244
|
-
|
|
289
|
+
{timeDisplayMode === "time" ? (
|
|
290
|
+
<>
|
|
291
|
+
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
|
292
|
+
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
293
|
+
</>
|
|
294
|
+
) : null}
|
|
245
295
|
</span>
|
|
246
296
|
|
|
247
297
|
{/* Seek bar — teal progress fill */}
|
|
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
256
306
|
aria-valuemin={0}
|
|
257
307
|
aria-valuemax={Math.round(duration)}
|
|
258
308
|
aria-valuenow={0}
|
|
259
|
-
className="flex-1 h-6 flex items-center cursor-pointer group"
|
|
309
|
+
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
|
|
260
310
|
// `touch-action: none` tells the browser we're handling every
|
|
261
311
|
// pointer gesture on this element ourselves. Without it, iOS
|
|
262
312
|
// Safari consumes horizontal swipes for its own swipe-back-to-
|
|
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
292
342
|
<button
|
|
293
343
|
type="button"
|
|
294
344
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
295
|
-
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
345
|
+
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
296
346
|
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
297
347
|
>
|
|
298
348
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
329
379
|
)}
|
|
330
380
|
</div>
|
|
331
381
|
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={() => setLoopEnabled(!loopEnabled)}
|
|
385
|
+
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
|
|
386
|
+
loopEnabled
|
|
387
|
+
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
388
|
+
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
389
|
+
}`}
|
|
390
|
+
title="Loop playback"
|
|
391
|
+
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
392
|
+
aria-pressed={loopEnabled}
|
|
393
|
+
>
|
|
394
|
+
Loop
|
|
395
|
+
</button>
|
|
396
|
+
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
|
|
400
|
+
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
|
401
|
+
title="Toggle time/frame display"
|
|
402
|
+
aria-label="Toggle time and frame display"
|
|
403
|
+
>
|
|
404
|
+
{timeDisplayMode === "time" ? "m:ss" : "frames"}
|
|
405
|
+
</button>
|
|
406
|
+
|
|
407
|
+
<form
|
|
408
|
+
onSubmit={handleJumpSubmit}
|
|
409
|
+
className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
|
|
410
|
+
>
|
|
411
|
+
<input
|
|
412
|
+
value={jumpFrame}
|
|
413
|
+
onChange={(e) => setJumpFrame(e.target.value)}
|
|
414
|
+
inputMode="numeric"
|
|
415
|
+
pattern="[0-9]*"
|
|
416
|
+
aria-label="Jump to frame"
|
|
417
|
+
placeholder="frame"
|
|
418
|
+
className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
|
|
419
|
+
onKeyDown={handleJumpKeyDown}
|
|
420
|
+
onBlur={commitJumpFrame}
|
|
421
|
+
/>
|
|
422
|
+
</form>
|
|
423
|
+
|
|
424
|
+
<div
|
|
425
|
+
className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
|
|
426
|
+
aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
|
|
427
|
+
>
|
|
428
|
+
{SHORTCUT_HINTS.map((shortcut) => (
|
|
429
|
+
<span
|
|
430
|
+
key={shortcut.key}
|
|
431
|
+
className="group relative rounded border border-neutral-800 px-1 py-0.5"
|
|
432
|
+
>
|
|
433
|
+
{shortcut.key}
|
|
434
|
+
<span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
|
|
435
|
+
{shortcut.label}
|
|
436
|
+
</span>
|
|
437
|
+
</span>
|
|
438
|
+
))}
|
|
439
|
+
</div>
|
|
440
|
+
|
|
332
441
|
{/* Timeline toggle */}
|
|
333
442
|
{onToggleTimeline !== undefined && (
|
|
334
443
|
<button
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
formatTimelineTickLabel,
|
|
3
4
|
generateTicks,
|
|
4
5
|
getDefaultDroppedTrack,
|
|
5
6
|
getTimelineCanvasHeight,
|
|
6
7
|
resolveTimelineAssetDrop,
|
|
7
8
|
getTimelinePlayheadLeft,
|
|
9
|
+
getTimelineScrollLeftForZoomAnchor,
|
|
8
10
|
getTimelineScrollLeftForZoomTransition,
|
|
11
|
+
shouldShowTimelineShortcutHint,
|
|
9
12
|
shouldHandleTimelineDeleteKey,
|
|
10
13
|
shouldAutoScrollTimeline,
|
|
11
14
|
} from "./Timeline";
|
|
@@ -78,6 +81,20 @@ describe("generateTicks", () => {
|
|
|
78
81
|
expect(major[0]).toBe(0);
|
|
79
82
|
}
|
|
80
83
|
});
|
|
84
|
+
|
|
85
|
+
it("uses denser major labels as timeline zoom increases", () => {
|
|
86
|
+
const fitTicks = generateTicks(180, 10);
|
|
87
|
+
const zoomedTicks = generateTicks(180, 48);
|
|
88
|
+
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
|
|
89
|
+
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
|
|
90
|
+
expect(zoomedTicks.minor).toContain(1);
|
|
91
|
+
expect(zoomedTicks.minor).toContain(4);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps labels readable instead of placing one at every tiny tick", () => {
|
|
95
|
+
const { major } = generateTicks(180, 80);
|
|
96
|
+
expect(major[1] - major[0]).toBe(2);
|
|
97
|
+
});
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
describe("formatTime", () => {
|
|
@@ -118,6 +135,20 @@ describe("formatTime", () => {
|
|
|
118
135
|
});
|
|
119
136
|
});
|
|
120
137
|
|
|
138
|
+
describe("formatTimelineTickLabel", () => {
|
|
139
|
+
it("uses minute-second labels for normal timeline intervals", () => {
|
|
140
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("uses hour labels for long timelines", () => {
|
|
144
|
+
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
148
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
describe("shouldAutoScrollTimeline", () => {
|
|
122
153
|
it("never auto-scrolls in fit mode", () => {
|
|
123
154
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
|
@@ -144,6 +175,48 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
|
144
175
|
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
|
|
145
176
|
});
|
|
146
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
180
|
+
it("preserves the time under the pointer when zooming in", () => {
|
|
181
|
+
expect(
|
|
182
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
183
|
+
pointerX: 300,
|
|
184
|
+
currentScrollLeft: 200,
|
|
185
|
+
gutter: 32,
|
|
186
|
+
currentPixelsPerSecond: 10,
|
|
187
|
+
nextPixelsPerSecond: 20,
|
|
188
|
+
duration: 120,
|
|
189
|
+
}),
|
|
190
|
+
).toBe(668);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("clamps negative scroll targets", () => {
|
|
194
|
+
expect(
|
|
195
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
196
|
+
pointerX: 300,
|
|
197
|
+
currentScrollLeft: 0,
|
|
198
|
+
gutter: 32,
|
|
199
|
+
currentPixelsPerSecond: 20,
|
|
200
|
+
nextPixelsPerSecond: 5,
|
|
201
|
+
duration: 120,
|
|
202
|
+
}),
|
|
203
|
+
).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("preserves current scroll when inputs are invalid", () => {
|
|
207
|
+
expect(
|
|
208
|
+
getTimelineScrollLeftForZoomAnchor({
|
|
209
|
+
pointerX: 300,
|
|
210
|
+
currentScrollLeft: 120,
|
|
211
|
+
gutter: 32,
|
|
212
|
+
currentPixelsPerSecond: 0,
|
|
213
|
+
nextPixelsPerSecond: 20,
|
|
214
|
+
duration: 120,
|
|
215
|
+
}),
|
|
216
|
+
).toBe(120);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
147
220
|
describe("getTimelinePlayheadLeft", () => {
|
|
148
221
|
it("converts time to a pixel offset from the gutter", () => {
|
|
149
222
|
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
|
|
@@ -165,6 +238,17 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
165
238
|
});
|
|
166
239
|
});
|
|
167
240
|
|
|
241
|
+
describe("shouldShowTimelineShortcutHint", () => {
|
|
242
|
+
it("shows the hint when the timeline does not vertically overflow", () => {
|
|
243
|
+
expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
|
|
244
|
+
expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("hides the hint when timeline tracks need vertical scrolling", () => {
|
|
248
|
+
expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
168
252
|
describe("shouldHandleTimelineDeleteKey", () => {
|
|
169
253
|
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
170
254
|
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type TimelineTrackStyle,
|
|
27
27
|
type TimelineTheme,
|
|
28
28
|
} from "./timelineTheme";
|
|
29
|
-
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
29
|
+
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
30
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
31
31
|
|
|
32
32
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
@@ -35,7 +35,7 @@ const TRACK_H = 72;
|
|
|
35
35
|
const RULER_H = 24;
|
|
36
36
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
37
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
38
|
+
const TIMELINE_SCROLL_BUFFER = 20;
|
|
39
39
|
|
|
40
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
41
|
icon: ReactNode;
|
|
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
|
91
|
-
|
|
91
|
+
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
|
|
92
|
+
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
|
|
93
|
+
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
94
|
+
const targetMajorPx = 128;
|
|
95
|
+
return (
|
|
96
|
+
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
100
|
+
const target = duration / 6;
|
|
101
|
+
return durationIntervals.find((interval) => interval >= target) ?? 60;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
|
|
105
|
+
let interval = majorInterval / 2;
|
|
106
|
+
if (majorInterval >= 30) interval = majorInterval / 6;
|
|
107
|
+
else if (majorInterval >= 15) interval = majorInterval / 3;
|
|
108
|
+
else if (majorInterval >= 5) interval = majorInterval / 5;
|
|
109
|
+
else if (majorInterval >= 1) interval = majorInterval / 4;
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
Number.isFinite(pixelsPerSecond) &&
|
|
113
|
+
(pixelsPerSecond ?? 0) > 0 &&
|
|
114
|
+
interval * (pixelsPerSecond ?? 0) < 20
|
|
115
|
+
) {
|
|
116
|
+
return Math.max(0.25, majorInterval / 2);
|
|
117
|
+
}
|
|
118
|
+
return Math.max(0.25, interval);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function generateTicks(
|
|
122
|
+
duration: number,
|
|
123
|
+
pixelsPerSecond?: number,
|
|
124
|
+
): { major: number[]; minor: number[] } {
|
|
92
125
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
|
93
126
|
return { major: [], minor: [] };
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
97
|
-
const minorInterval = Math.max(0.25, majorInterval / 2);
|
|
127
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
128
|
+
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
|
98
129
|
const major: number[] = [];
|
|
99
130
|
const minor: number[] = [];
|
|
100
|
-
const maxTicks =
|
|
131
|
+
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
101
132
|
for (
|
|
102
133
|
let t = 0;
|
|
103
134
|
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
|
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
|
|
113
144
|
return { major, minor };
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
|
|
148
|
+
if (!Number.isFinite(time)) return "0:00";
|
|
149
|
+
const safeTime = Math.max(0, time);
|
|
150
|
+
if (majorInterval < 1) {
|
|
151
|
+
const totalTenths = Math.round(safeTime * 10);
|
|
152
|
+
const wholeSeconds = Math.floor(totalTenths / 10);
|
|
153
|
+
const tenth = totalTenths % 10;
|
|
154
|
+
return `${formatTime(wholeSeconds)}.${tenth}`;
|
|
155
|
+
}
|
|
156
|
+
if (duration >= 3600 || safeTime >= 3600) {
|
|
157
|
+
const totalSeconds = Math.floor(safeTime);
|
|
158
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
159
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
160
|
+
const seconds = totalSeconds % 60;
|
|
161
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
162
|
+
}
|
|
163
|
+
return formatTime(safeTime);
|
|
164
|
+
}
|
|
165
|
+
|
|
116
166
|
export function shouldAutoScrollTimeline(
|
|
117
167
|
zoomMode: ZoomMode,
|
|
118
168
|
scrollWidth: number,
|
|
@@ -131,6 +181,32 @@ export function getTimelineScrollLeftForZoomTransition(
|
|
|
131
181
|
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
|
|
132
182
|
return currentScrollLeft;
|
|
133
183
|
}
|
|
184
|
+
|
|
185
|
+
export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
186
|
+
pointerX: number;
|
|
187
|
+
currentScrollLeft: number;
|
|
188
|
+
gutter: number;
|
|
189
|
+
currentPixelsPerSecond: number;
|
|
190
|
+
nextPixelsPerSecond: number;
|
|
191
|
+
duration: number;
|
|
192
|
+
}): number {
|
|
193
|
+
const currentPps = Math.max(0, input.currentPixelsPerSecond);
|
|
194
|
+
const nextPps = Math.max(0, input.nextPixelsPerSecond);
|
|
195
|
+
if (
|
|
196
|
+
!Number.isFinite(input.pointerX) ||
|
|
197
|
+
!Number.isFinite(input.currentScrollLeft) ||
|
|
198
|
+
!Number.isFinite(input.duration) ||
|
|
199
|
+
input.duration <= 0 ||
|
|
200
|
+
currentPps <= 0 ||
|
|
201
|
+
nextPps <= 0
|
|
202
|
+
) {
|
|
203
|
+
return Math.max(0, input.currentScrollLeft);
|
|
204
|
+
}
|
|
205
|
+
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
|
206
|
+
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
|
207
|
+
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
|
208
|
+
}
|
|
209
|
+
|
|
134
210
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
135
211
|
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
|
|
136
212
|
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
@@ -140,6 +216,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
140
216
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
141
217
|
}
|
|
142
218
|
|
|
219
|
+
export function shouldShowTimelineShortcutHint(
|
|
220
|
+
scrollHeight: number,
|
|
221
|
+
clientHeight: number,
|
|
222
|
+
): boolean {
|
|
223
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
224
|
+
return scrollHeight - clientHeight <= 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
143
227
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
144
228
|
key: string;
|
|
145
229
|
metaKey?: boolean;
|
|
@@ -304,6 +388,8 @@ export const Timeline = memo(function Timeline({
|
|
|
304
388
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
305
389
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
306
390
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
391
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
392
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
307
393
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
308
394
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
309
395
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -348,30 +434,51 @@ export const Timeline = memo(function Timeline({
|
|
|
348
434
|
onDeleteElementRef.current = onDeleteElement;
|
|
349
435
|
const suppressClickRef = useRef(false);
|
|
350
436
|
const [showPopover, setShowPopover] = useState(false);
|
|
437
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
351
438
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
352
439
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
440
|
+
const shortcutHintRafRef = useRef(0);
|
|
441
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
442
|
+
const scroll = scrollRef.current;
|
|
443
|
+
setShowShortcutHint(
|
|
444
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
445
|
+
);
|
|
446
|
+
}, []);
|
|
447
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
448
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
449
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
450
|
+
shortcutHintRafRef.current = 0;
|
|
451
|
+
syncShortcutHintVisibility();
|
|
452
|
+
});
|
|
453
|
+
}, [syncShortcutHintVisibility]);
|
|
353
454
|
|
|
354
455
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
355
456
|
// useMountEffect can't work here because the component returns null on first
|
|
356
457
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
357
458
|
// effect fires and the ResizeObserver is never created.
|
|
358
|
-
const setContainerRef = useCallback(
|
|
359
|
-
|
|
360
|
-
roRef.current
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
459
|
+
const setContainerRef = useCallback(
|
|
460
|
+
(el: HTMLDivElement | null) => {
|
|
461
|
+
if (roRef.current) {
|
|
462
|
+
roRef.current.disconnect();
|
|
463
|
+
roRef.current = null;
|
|
464
|
+
}
|
|
465
|
+
containerRef.current = el;
|
|
466
|
+
if (!el) return;
|
|
467
|
+
setViewportWidth(el.clientWidth);
|
|
468
|
+
scheduleShortcutHintVisibilitySync();
|
|
469
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
470
|
+
setViewportWidth(entry.contentRect.width);
|
|
471
|
+
scheduleShortcutHintVisibilitySync();
|
|
472
|
+
});
|
|
473
|
+
roRef.current.observe(el);
|
|
474
|
+
},
|
|
475
|
+
[scheduleShortcutHintVisibilitySync],
|
|
476
|
+
);
|
|
371
477
|
|
|
372
478
|
// Clean up ResizeObserver on unmount
|
|
373
479
|
useMountEffect(() => () => {
|
|
374
480
|
roRef.current?.disconnect();
|
|
481
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
375
482
|
});
|
|
376
483
|
|
|
377
484
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -416,6 +523,7 @@ export const Timeline = memo(function Timeline({
|
|
|
416
523
|
}
|
|
417
524
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
418
525
|
}, [draggedClip, trackOrder]);
|
|
526
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
419
527
|
const selectedElement = useMemo(
|
|
420
528
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
421
529
|
[elements, selectedElementId],
|
|
@@ -433,7 +541,11 @@ export const Timeline = memo(function Timeline({
|
|
|
433
541
|
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
|
434
542
|
const zoomModeRef = useRef(zoomMode);
|
|
435
543
|
zoomModeRef.current = zoomMode;
|
|
544
|
+
const manualZoomPercentRef = useRef(manualZoomPercent);
|
|
545
|
+
manualZoomPercentRef.current = manualZoomPercent;
|
|
436
546
|
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
|
547
|
+
const fitPpsRef = useRef(fitPps);
|
|
548
|
+
fitPpsRef.current = fitPps;
|
|
437
549
|
|
|
438
550
|
const durationRef = useRef(effectiveDuration);
|
|
439
551
|
durationRef.current = effectiveDuration;
|
|
@@ -922,7 +1034,16 @@ export const Timeline = memo(function Timeline({
|
|
|
922
1034
|
cancelAnimationFrame(dragScrollRaf.current);
|
|
923
1035
|
}, []);
|
|
924
1036
|
|
|
925
|
-
const { major, minor } = useMemo(
|
|
1037
|
+
const { major, minor } = useMemo(
|
|
1038
|
+
() => generateTicks(effectiveDuration, pps),
|
|
1039
|
+
[effectiveDuration, pps],
|
|
1040
|
+
);
|
|
1041
|
+
const majorTickInterval =
|
|
1042
|
+
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1043
|
+
useEffect(() => {
|
|
1044
|
+
syncShortcutHintVisibility();
|
|
1045
|
+
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1046
|
+
|
|
926
1047
|
const getPreviewElement = useCallback(
|
|
927
1048
|
(element: TimelineElement): TimelineElement => {
|
|
928
1049
|
if (resizingClip?.element.id === element.id) {
|
|
@@ -1008,6 +1129,57 @@ export const Timeline = memo(function Timeline({
|
|
|
1008
1129
|
[onAssetDrop, onFileDrop],
|
|
1009
1130
|
);
|
|
1010
1131
|
|
|
1132
|
+
const handlePinchWheel = useCallback(
|
|
1133
|
+
(e: WheelEvent) => {
|
|
1134
|
+
if (!e.ctrlKey) return;
|
|
1135
|
+
const scroll = scrollRef.current;
|
|
1136
|
+
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
e.preventDefault();
|
|
1141
|
+
e.stopPropagation();
|
|
1142
|
+
|
|
1143
|
+
const rect = scroll.getBoundingClientRect();
|
|
1144
|
+
const pointerX = e.clientX - rect.left;
|
|
1145
|
+
const nextZoomPercent = getPinchTimelineZoomPercent(
|
|
1146
|
+
e.deltaY,
|
|
1147
|
+
zoomModeRef.current,
|
|
1148
|
+
manualZoomPercentRef.current,
|
|
1149
|
+
);
|
|
1150
|
+
if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const nextPps = fitPpsRef.current * (nextZoomPercent / 100);
|
|
1155
|
+
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
|
1156
|
+
pointerX,
|
|
1157
|
+
currentScrollLeft: scroll.scrollLeft,
|
|
1158
|
+
gutter: GUTTER,
|
|
1159
|
+
currentPixelsPerSecond: ppsRef.current,
|
|
1160
|
+
nextPixelsPerSecond: nextPps,
|
|
1161
|
+
duration: durationRef.current,
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
setZoomMode("manual");
|
|
1165
|
+
setManualZoomPercent(nextZoomPercent);
|
|
1166
|
+
requestAnimationFrame(() => {
|
|
1167
|
+
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
1168
|
+
scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft);
|
|
1169
|
+
});
|
|
1170
|
+
},
|
|
1171
|
+
[setManualZoomPercent, setZoomMode],
|
|
1172
|
+
);
|
|
1173
|
+
|
|
1174
|
+
useEffect(() => {
|
|
1175
|
+
const scroll = scrollRef.current;
|
|
1176
|
+
if (!scroll) return;
|
|
1177
|
+
scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
|
|
1178
|
+
return () => {
|
|
1179
|
+
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
1180
|
+
};
|
|
1181
|
+
}, [handlePinchWheel, timelineReady, elements.length]);
|
|
1182
|
+
|
|
1011
1183
|
if (!timelineReady || elements.length === 0) {
|
|
1012
1184
|
return (
|
|
1013
1185
|
<div
|
|
@@ -1096,7 +1268,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1096
1268
|
);
|
|
1097
1269
|
}
|
|
1098
1270
|
|
|
1099
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1100
1271
|
const draggedElement = draggedClip?.element ?? null;
|
|
1101
1272
|
const activeDraggedElement =
|
|
1102
1273
|
draggedClip?.started === true && draggedElement
|
|
@@ -1170,7 +1341,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1170
1341
|
<div
|
|
1171
1342
|
ref={setContainerRef}
|
|
1172
1343
|
aria-label="Timeline"
|
|
1173
|
-
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1344
|
+
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1174
1345
|
style={{
|
|
1175
1346
|
touchAction: "pan-x pan-y",
|
|
1176
1347
|
background: theme.shellBackground,
|
|
@@ -1239,7 +1410,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1239
1410
|
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
1240
1411
|
style={{ color: theme.tickText }}
|
|
1241
1412
|
>
|
|
1242
|
-
{
|
|
1413
|
+
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
1243
1414
|
</span>
|
|
1244
1415
|
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
1245
1416
|
</div>
|
|
@@ -1509,8 +1680,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1509
1680
|
</div>
|
|
1510
1681
|
</div>
|
|
1511
1682
|
|
|
1512
|
-
{/* Keyboard shortcut hint
|
|
1513
|
-
{!showPopover && !rangeSelection && (
|
|
1683
|
+
{/* Keyboard shortcut hint */}
|
|
1684
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1514
1685
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1515
1686
|
<div
|
|
1516
1687
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|