mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
feat(timeline): refine clip editing and place yellow captions above footage
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
} from "../types";
|
||||
|
||||
interface UseAnnotationRegionCommandsParams {
|
||||
onSelectAnnotation: (id: string | null) => void;
|
||||
setAnnotationRegions: Dispatch<SetStateAction<AnnotationRegion[]>>;
|
||||
selectedAnnotationId: string | null;
|
||||
setSelectedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -19,6 +20,7 @@ interface UseAnnotationRegionCommandsParams {
|
||||
}
|
||||
|
||||
export function useAnnotationRegionCommands({
|
||||
onSelectAnnotation,
|
||||
setAnnotationRegions,
|
||||
selectedAnnotationId,
|
||||
setSelectedAnnotationId,
|
||||
@@ -42,14 +44,14 @@ export function useAnnotationRegionCommands({
|
||||
trackIndex,
|
||||
};
|
||||
setAnnotationRegions((current) => [...current, newRegion]);
|
||||
setSelectedAnnotationId(id);
|
||||
onSelectAnnotation(id);
|
||||
setSelectedZoomId(null);
|
||||
},
|
||||
[
|
||||
onSelectAnnotation,
|
||||
nextAnnotationIdRef,
|
||||
nextAnnotationZIndexRef,
|
||||
setAnnotationRegions,
|
||||
setSelectedAnnotationId,
|
||||
setSelectedZoomId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback
|
||||
import type { AudioRegion, EditorEffectSection } from "../types";
|
||||
|
||||
interface UseAudioRegionCommandsParams {
|
||||
setSelectedClipId: Dispatch<SetStateAction<string | null>>;
|
||||
setAudioRegions: Dispatch<SetStateAction<AudioRegion[]>>;
|
||||
selectedAudioId: string | null;
|
||||
setSelectedAudioId: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -14,6 +15,7 @@ interface UseAudioRegionCommandsParams {
|
||||
}
|
||||
|
||||
export function useAudioRegionCommands({
|
||||
setSelectedClipId,
|
||||
setAudioRegions,
|
||||
selectedAudioId,
|
||||
setSelectedAudioId,
|
||||
@@ -29,6 +31,7 @@ export function useAudioRegionCommands({
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedClipId(null);
|
||||
setSelectedCaptionId(null);
|
||||
setActiveEffectSection("audio");
|
||||
}
|
||||
@@ -36,6 +39,7 @@ export function useAudioRegionCommands({
|
||||
[
|
||||
setActiveEffectSection,
|
||||
setSelectedAnnotationId,
|
||||
setSelectedClipId,
|
||||
setSelectedAudioId,
|
||||
setSelectedCaptionId,
|
||||
setSelectedZoomId,
|
||||
@@ -58,6 +62,7 @@ export function useAudioRegionCommands({
|
||||
setSelectedAudioId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedClipId(null);
|
||||
setSelectedCaptionId(null);
|
||||
setActiveEffectSection("audio");
|
||||
},
|
||||
@@ -66,6 +71,7 @@ export function useAudioRegionCommands({
|
||||
setActiveEffectSection,
|
||||
setAudioRegions,
|
||||
setSelectedAnnotationId,
|
||||
setSelectedClipId,
|
||||
setSelectedAudioId,
|
||||
setSelectedCaptionId,
|
||||
setSelectedZoomId,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import type { ClipSequenceSpan } from "../timeline/core/timelineTypes";
|
||||
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { changeClipSpan } from "../clipSpanChange";
|
||||
import { planClipSpeedChange } from "../clipSpeedChange";
|
||||
import {
|
||||
packClipSequence,
|
||||
reorderClipSequence,
|
||||
rippleRegionAnchors,
|
||||
rippleRegions,
|
||||
} from "../clipSequence";
|
||||
import { getClipSourceStartMs, type AnnotationRegion, type AudioRegion } from "../types";
|
||||
import { planClipSplit } from "../clipSplit";
|
||||
import type { ClipRegion, EditorEffectSection, ZoomRegion } from "../types";
|
||||
import { supportsPreviewPlaybackRate } from "../videoPlayback/playbackRate";
|
||||
@@ -14,6 +20,8 @@ type Translator = (
|
||||
) => string;
|
||||
|
||||
interface UseClipRegionCommandsParams {
|
||||
setAnnotationRegions: Dispatch<SetStateAction<AnnotationRegion[]>>;
|
||||
setAudioRegions: Dispatch<SetStateAction<AudioRegion[]>>;
|
||||
sourceDurationMs: number;
|
||||
clipRegions: ClipRegion[];
|
||||
setClipRegions: Dispatch<SetStateAction<ClipRegion[]>>;
|
||||
@@ -31,10 +39,11 @@ interface UseClipRegionCommandsParams {
|
||||
}
|
||||
|
||||
export function useClipRegionCommands({
|
||||
setAnnotationRegions,
|
||||
setAudioRegions,
|
||||
sourceDurationMs,
|
||||
clipRegions,
|
||||
setClipRegions,
|
||||
zoomRegions,
|
||||
setZoomRegions,
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
@@ -46,6 +55,17 @@ export function useClipRegionCommands({
|
||||
nextClipIdRef,
|
||||
t,
|
||||
}: UseClipRegionCommandsParams) {
|
||||
const applySequence = useCallback(
|
||||
(edited: ClipRegion[]) => {
|
||||
const next = packClipSequence(edited);
|
||||
setClipRegions(next);
|
||||
setZoomRegions((current) => rippleRegions(current, clipRegions, next));
|
||||
setAnnotationRegions((current) => rippleRegions(current, clipRegions, next));
|
||||
setAudioRegions((current) => rippleRegionAnchors(current, clipRegions, next));
|
||||
},
|
||||
[clipRegions, setClipRegions, setZoomRegions, setAnnotationRegions, setAudioRegions],
|
||||
);
|
||||
|
||||
const handleSelectClip = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectedClipId(id);
|
||||
@@ -88,37 +108,25 @@ export function useClipRegionCommands({
|
||||
);
|
||||
|
||||
const handleClipSpanChange = useCallback(
|
||||
(id: string, span: Span) => {
|
||||
(id: string, span: ClipSequenceSpan) => {
|
||||
const oldClip = clipRegions.find((clip) => clip.id === id);
|
||||
const newStart = Math.round(span.start);
|
||||
const newEnd = Math.round(span.end);
|
||||
|
||||
if (oldClip) {
|
||||
const startDelta = newStart - oldClip.startMs;
|
||||
const endDelta = newEnd - oldClip.endMs;
|
||||
if (Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0) {
|
||||
setZoomRegions((current) =>
|
||||
current.map((zoom) =>
|
||||
zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs
|
||||
? {
|
||||
...zoom,
|
||||
startMs: zoom.startMs + startDelta,
|
||||
endMs: zoom.endMs + startDelta,
|
||||
}
|
||||
: zoom,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!oldClip) return;
|
||||
if (span.sequenceIndex !== undefined) {
|
||||
applySequence(reorderClipSequence(clipRegions, id, span.sequenceIndex));
|
||||
return;
|
||||
}
|
||||
|
||||
setClipRegions((current) =>
|
||||
current.map((clip) => {
|
||||
if (clip.id !== id) return clip;
|
||||
return changeClipSpan(clip, newStart, newEnd, sourceDurationMs);
|
||||
}),
|
||||
applySequence(
|
||||
clipRegions.map((clip) =>
|
||||
clip.id === id
|
||||
? changeClipSpan(clip, newStart, newEnd, sourceDurationMs)
|
||||
: clip,
|
||||
),
|
||||
);
|
||||
},
|
||||
[clipRegions, setClipRegions, setZoomRegions, sourceDurationMs],
|
||||
[clipRegions, applySequence, sourceDurationMs],
|
||||
);
|
||||
|
||||
const handleClipSpeedChange = useCallback(
|
||||
@@ -133,26 +141,28 @@ export function useClipRegionCommands({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const plan = planClipSpeedChange({ clipRegions, zoomRegions, selectedClipId, speed });
|
||||
if (!plan) return;
|
||||
if ("blockedReason" in plan) {
|
||||
toast.warning(
|
||||
plan.blockedReason === "clip-overlap"
|
||||
? t(
|
||||
"editor.timeline.speedClipOverlap",
|
||||
"Speed change would overlap the next clip. Move or split clips before slowing this section.",
|
||||
)
|
||||
: t(
|
||||
"editor.timeline.speedZoomOverlap",
|
||||
"Speed change would overlap another zoom. Move or delete the overlapping zoom first.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setClipRegions(plan.clipRegions);
|
||||
setZoomRegions(plan.zoomRegions);
|
||||
|
||||
applySequence(
|
||||
clipRegions.map((clip) =>
|
||||
clip.id === selectedClipId
|
||||
? {
|
||||
...clip,
|
||||
sourceStartMs: getClipSourceStartMs(clip),
|
||||
speed,
|
||||
endMs:
|
||||
clip.startMs +
|
||||
Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
((clip.endMs - clip.startMs) * clip.speed) / speed,
|
||||
),
|
||||
),
|
||||
}
|
||||
: clip,
|
||||
),
|
||||
);
|
||||
},
|
||||
[clipRegions, selectedClipId, setClipRegions, setZoomRegions, t, zoomRegions],
|
||||
[clipRegions, selectedClipId, applySequence, t],
|
||||
);
|
||||
|
||||
const handleClipMutedChange = useCallback(
|
||||
@@ -178,11 +188,10 @@ export function useClipRegionCommands({
|
||||
|
||||
const handleClipDelete = useCallback(
|
||||
(id: string) => {
|
||||
// Other tracks have their own timeline positions; deleting footage is not a ripple edit.
|
||||
setClipRegions((current) => current.filter((clip) => clip.id !== id));
|
||||
applySequence(clipRegions.filter((clip) => clip.id !== id));
|
||||
if (selectedClipId === id) setSelectedClipId(null);
|
||||
},
|
||||
[selectedClipId, setClipRegions, setSelectedClipId],
|
||||
[clipRegions, selectedClipId, applySequence, setSelectedClipId],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SHORTCUTS } from "@/lib/shortcuts";
|
||||
import { useEditorGlobalInteractions } from "./useEditorGlobalInteractions";
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: (effect: () => void) => effect(),
|
||||
useRef: (current: unknown) => ({ current }),
|
||||
}));
|
||||
class Element {
|
||||
isContentEditable = false;
|
||||
}
|
||||
class Input extends Element {}
|
||||
class Textarea extends Element {}
|
||||
class Select extends Element {}
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function setup(binding = DEFAULT_SHORTCUTS.playPause) {
|
||||
vi.stubGlobal("HTMLInputElement", Input);
|
||||
vi.stubGlobal("HTMLTextAreaElement", Textarea);
|
||||
vi.stubGlobal("HTMLSelectElement", Select);
|
||||
const handlers = new Map<string, (event: KeyboardEvent) => void>();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: (name: string, handler: (event: KeyboardEvent) => void) =>
|
||||
handlers.set(name, handler),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
const playback = {
|
||||
video: {},
|
||||
isPlaying: false,
|
||||
pause: vi.fn(() => {
|
||||
playback.isPlaying = false;
|
||||
}),
|
||||
};
|
||||
const startPlayback = vi.fn(() => {
|
||||
playback.isPlaying = true;
|
||||
});
|
||||
useEditorGlobalInteractions({
|
||||
timeline: {},
|
||||
videoPlaybackRef: { current: playback },
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, playPause: binding },
|
||||
isMac: true,
|
||||
startPlayback,
|
||||
handleUndo: vi.fn(),
|
||||
handleRedo: vi.fn(),
|
||||
} as unknown as Parameters<typeof useEditorGlobalInteractions>[0]);
|
||||
const send = (type = "keydown", options: Record<string, unknown> = {}) => {
|
||||
const event = {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
target: new Element(),
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
repeat: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
...options,
|
||||
};
|
||||
handlers.get(type)!(event as unknown as KeyboardEvent);
|
||||
return event;
|
||||
};
|
||||
return { send, playback, startPlayback };
|
||||
}
|
||||
|
||||
describe("editor playback shortcut", () => {
|
||||
it("consumes keydown and keyup and toggles once per physical press", () => {
|
||||
const { send, playback, startPlayback } = setup();
|
||||
const down = send();
|
||||
expect(down.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(down.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
send("keydown", { repeat: true });
|
||||
send(); // Even duplicate keydowns without the repeat flag belong to the held key.
|
||||
expect(startPlayback).toHaveBeenCalledOnce();
|
||||
expect(playback.pause).not.toHaveBeenCalled();
|
||||
const up = send("keyup");
|
||||
expect(up.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(up.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
send();
|
||||
expect(playback.pause).toHaveBeenCalledOnce();
|
||||
send("keyup");
|
||||
send();
|
||||
expect(startPlayback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("ignores repeat-only events and recovers after losing window focus", () => {
|
||||
const { send, startPlayback, playback } = setup();
|
||||
send("keydown", { repeat: true });
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
send();
|
||||
send("blur");
|
||||
send();
|
||||
expect(playback.pause).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each([
|
||||
new Input(),
|
||||
new Textarea(),
|
||||
new Select(),
|
||||
Object.assign(new Element(), { isContentEditable: true }),
|
||||
])("leaves editable controls alone", (target) => {
|
||||
const { send, startPlayback } = setup();
|
||||
expect(send("keydown", { target }).preventDefault).not.toHaveBeenCalled();
|
||||
expect(send("keyup", { target }).preventDefault).not.toHaveBeenCalled();
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
it("leaves composition and already handled events alone", () => {
|
||||
const { send, startPlayback } = setup();
|
||||
expect(send("keydown", { isComposing: true }).preventDefault).not.toHaveBeenCalled();
|
||||
expect(send("keydown", { defaultPrevented: true }).preventDefault).not.toHaveBeenCalled();
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
it("supports customized shortcuts and releases even if modifiers change", () => {
|
||||
const { send, startPlayback, playback } = setup({ key: "k", ctrl: true });
|
||||
expect(send().preventDefault).not.toHaveBeenCalled();
|
||||
send("keydown", { key: "k", code: "KeyK", metaKey: true });
|
||||
expect(startPlayback).toHaveBeenCalledOnce();
|
||||
send("keyup", { key: "k", code: "KeyK", metaKey: false });
|
||||
send("keydown", { key: "k", code: "KeyK", metaKey: true });
|
||||
expect(playback.pause).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import type { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import type { useTimelineState } from "../state/useTimelineState";
|
||||
@@ -23,12 +23,36 @@ export function useEditorGlobalInteractions({
|
||||
handleRedo,
|
||||
startPlayback,
|
||||
}: Input) {
|
||||
const heldPlaybackKey = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const consumePlaybackKey = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
// React Aria buttons also handle Space. Own both halves of this shortcut
|
||||
// so their press handler cannot toggle playback a second time.
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
const keyIdentity = (event: KeyboardEvent) => event.code || event.key.toLowerCase();
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (heldPlaybackKey.current !== keyIdentity(event)) return;
|
||||
heldPlaybackKey.current = null;
|
||||
consumePlaybackKey(event);
|
||||
};
|
||||
const releasePlaybackKey = () => {
|
||||
heldPlaybackKey.current = null;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (heldPlaybackKey.current === keyIdentity(event)) {
|
||||
consumePlaybackKey(event);
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented || event.isComposing) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("[data-recording-library]")) return;
|
||||
const editable =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target?.isContentEditable;
|
||||
const primaryModifier = isMac ? event.metaKey : event.ctrlKey;
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -49,14 +73,22 @@ export function useEditorGlobalInteractions({
|
||||
return;
|
||||
}
|
||||
if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return;
|
||||
event.preventDefault();
|
||||
consumePlaybackKey(event);
|
||||
if (event.repeat) return;
|
||||
heldPlaybackKey.current = keyIdentity(event);
|
||||
const playback = videoPlaybackRef.current;
|
||||
if (!playback?.video) return;
|
||||
if (!playback.isPlaying) startPlayback();
|
||||
else playback.pause();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.addEventListener("keyup", handleKeyUp, { capture: true });
|
||||
window.addEventListener("blur", releasePlaybackKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.removeEventListener("keyup", handleKeyUp, { capture: true });
|
||||
window.removeEventListener("blur", releasePlaybackKey);
|
||||
};
|
||||
}, [shortcuts, isMac, handleUndo, handleRedo, startPlayback, videoPlaybackRef]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -124,6 +124,7 @@ export function useTimelineEditingController(input: Input) {
|
||||
handleSeek: playback.handleSeek,
|
||||
});
|
||||
const zoomCommands = useZoomRegionCommands({
|
||||
setSelectedClipId: timeline.setSelectedClipId,
|
||||
videoPath: input.videoPath,
|
||||
setZoomRegions: timeline.setZoomRegions,
|
||||
selectedZoomId: timeline.selectedZoomId,
|
||||
@@ -140,6 +141,7 @@ export function useTimelineEditingController(input: Input) {
|
||||
(id: string | null) => {
|
||||
timeline.setSelectedAnnotationId(id);
|
||||
if (id) {
|
||||
timeline.setSelectedClipId(null);
|
||||
timeline.setSelectedZoomId(null);
|
||||
timeline.setSelectedAudioId(null);
|
||||
timeline.setSelectedCaptionId(null);
|
||||
@@ -147,6 +149,7 @@ export function useTimelineEditingController(input: Input) {
|
||||
},
|
||||
[
|
||||
timeline.setSelectedAnnotationId,
|
||||
timeline.setSelectedClipId,
|
||||
timeline.setSelectedZoomId,
|
||||
timeline.setSelectedAudioId,
|
||||
timeline.setSelectedCaptionId,
|
||||
@@ -172,6 +175,8 @@ export function useTimelineEditingController(input: Input) {
|
||||
input.pendingFreshRecordingAutoSuggestTelemetryCountRef,
|
||||
});
|
||||
const clipCommands = useClipRegionCommands({
|
||||
setAnnotationRegions: timeline.setAnnotationRegions,
|
||||
setAudioRegions: timeline.setAudioRegions,
|
||||
sourceDurationMs: input.duration * 1000,
|
||||
clipRegions: timeline.clipRegions,
|
||||
setClipRegions: timeline.setClipRegions,
|
||||
@@ -188,6 +193,7 @@ export function useTimelineEditingController(input: Input) {
|
||||
t: input.t,
|
||||
});
|
||||
const audioCommands = useAudioRegionCommands({
|
||||
setSelectedClipId: timeline.setSelectedClipId,
|
||||
setAudioRegions: timeline.setAudioRegions,
|
||||
selectedAudioId: timeline.selectedAudioId,
|
||||
setSelectedAudioId: timeline.setSelectedAudioId,
|
||||
@@ -198,6 +204,7 @@ export function useTimelineEditingController(input: Input) {
|
||||
nextAudioIdRef: input.nextAudioIdRef,
|
||||
});
|
||||
const annotationCommands = useAnnotationRegionCommands({
|
||||
onSelectAnnotation: handleSelectAnnotation,
|
||||
setAnnotationRegions: timeline.setAnnotationRegions,
|
||||
selectedAnnotationId: timeline.selectedAnnotationId,
|
||||
setSelectedAnnotationId: timeline.setSelectedAnnotationId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: mutable timeline bootstrap refs intentionally do not trigger effects. */
|
||||
import { type MutableRefObject, useCallback, useEffect, useMemo } from "react";
|
||||
import { closeClipGaps, rippleRegionAnchors, rippleRegions } from "../clipSequence";
|
||||
import { projectCaptionCues } from "../captionTimeline";
|
||||
import { deriveNextId } from "../projectPersistence";
|
||||
import type { useTimelineState } from "../state/useTimelineState";
|
||||
@@ -57,7 +58,19 @@ export function useTimelineProjection({
|
||||
nextRegions.map(({ id }) => id),
|
||||
);
|
||||
}
|
||||
timeline.setClipRegions(nextRegions);
|
||||
const sequence = closeClipGaps(nextRegions);
|
||||
timeline.setClipRegions(sequence);
|
||||
if (trimRegions.length > 0) {
|
||||
timeline.setZoomRegions((current) =>
|
||||
rippleRegions(current, nextRegions, sequence),
|
||||
);
|
||||
timeline.setAnnotationRegions((current) =>
|
||||
rippleRegions(current, nextRegions, sequence),
|
||||
);
|
||||
timeline.setAudioRegions((current) =>
|
||||
rippleRegionAnchors(current, nextRegions, sequence),
|
||||
);
|
||||
}
|
||||
}
|
||||
initializedRef.current = true;
|
||||
return;
|
||||
@@ -96,7 +109,8 @@ export function useTimelineProjection({
|
||||
);
|
||||
const timelinePlayheadTime = currentTime;
|
||||
const timelineDuration = useMemo(
|
||||
() => getTimelineDurationMs(clipRegions, duration * 1000) / 1000,
|
||||
() =>
|
||||
getTimelineDurationMs(clipRegions, initializedRef.current ? 0 : duration * 1000) / 1000,
|
||||
[clipRegions, duration],
|
||||
);
|
||||
const effectiveSpeedRegions = useMemo<SpeedRegion[]>(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../types";
|
||||
|
||||
interface UseZoomRegionCommandsParams {
|
||||
setSelectedClipId: Dispatch<SetStateAction<string | null>>;
|
||||
videoPath: string | null;
|
||||
setZoomRegions: Dispatch<SetStateAction<ZoomRegion[]>>;
|
||||
selectedZoomId: string | null;
|
||||
@@ -25,6 +26,7 @@ interface UseZoomRegionCommandsParams {
|
||||
}
|
||||
|
||||
export function useZoomRegionCommands({
|
||||
setSelectedClipId,
|
||||
videoPath,
|
||||
setZoomRegions,
|
||||
selectedZoomId,
|
||||
@@ -43,6 +45,7 @@ export function useZoomRegionCommands({
|
||||
if (id) {
|
||||
setActiveEffectSection("zoom");
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedClipId(null);
|
||||
setSelectedAudioId(null);
|
||||
setSelectedCaptionId(null);
|
||||
} else {
|
||||
@@ -52,6 +55,7 @@ export function useZoomRegionCommands({
|
||||
[
|
||||
setActiveEffectSection,
|
||||
setSelectedAnnotationId,
|
||||
setSelectedClipId,
|
||||
setSelectedAudioId,
|
||||
setSelectedCaptionId,
|
||||
setSelectedZoomId,
|
||||
@@ -82,12 +86,14 @@ export function useZoomRegionCommands({
|
||||
setZoomRegions((current) => [...current, newRegion]);
|
||||
setSelectedZoomId(id);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedClipId(null);
|
||||
setSelectedCaptionId(null);
|
||||
},
|
||||
[
|
||||
markFreshRecordingSuggestion,
|
||||
nextZoomIdRef,
|
||||
setSelectedAnnotationId,
|
||||
setSelectedClipId,
|
||||
setSelectedCaptionId,
|
||||
setSelectedZoomId,
|
||||
setZoomRegions,
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import {
|
||||
Gauge,
|
||||
MagnifyingGlassPlus as ZoomIn,
|
||||
ChatCircle as MessageSquare,
|
||||
MusicNotes as Music,
|
||||
Scissors,
|
||||
SpeakerX,
|
||||
MagnifyingGlassPlus as ZoomIn,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ClipFilmstrip } from "./components/filmstrip/ClipFilmstrip";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import { useMemo } from "react";
|
||||
import type { Span, GetSpanFromDragEvent, GetSpanFromResizeEvent } from "dnd-timeline";
|
||||
import { useItem, useTimelineContext } from "dnd-timeline";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useDndMonitor } from "@dnd-kit/core";
|
||||
import { useTimelinePresentation } from "./core/TimelinePresentation";
|
||||
import { getRegionDisplaySpan, snapRegionSpan } from "./core/clipPresentation";
|
||||
import { resolveDragEnd, resolveResizeEnd } from "./dnd/engine";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatClipSpeedLabel } from "../clipSpeedChange";
|
||||
import { getTimeAtClipSeam, type ClipPresentation } from "./core/clipPresentation";
|
||||
import { formatPlayheadTime } from "./core/time";
|
||||
import AudioWaveform from "./components/waveform/AudioWaveform";
|
||||
import type { AudioPeaksData } from "./core/timelineTypes";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
clipPresentation?: ClipPresentation[];
|
||||
sharedLeftGrip?: boolean;
|
||||
sharedRightGrip?: boolean;
|
||||
displaySpan?: Span;
|
||||
embedded?: boolean;
|
||||
videoPath?: string | null;
|
||||
sourceSpan?: Span;
|
||||
id: string;
|
||||
@@ -28,6 +38,7 @@ interface ItemProps {
|
||||
children: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onSelectId?: (id: string) => void;
|
||||
zoomDepth?: number;
|
||||
zoomMode?: "auto" | "manual";
|
||||
@@ -53,14 +64,20 @@ const ZOOM_LABELS: Record<number, string> = {
|
||||
};
|
||||
|
||||
export default function Item({
|
||||
clipPresentation: suppliedPresentation,
|
||||
id,
|
||||
sharedLeftGrip = false,
|
||||
sharedRightGrip = false,
|
||||
embedded = false,
|
||||
videoPath,
|
||||
sourceSpan,
|
||||
span,
|
||||
displaySpan: suppliedDisplaySpan,
|
||||
rowId,
|
||||
disabled = false,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
onDoubleClick,
|
||||
onSelectId,
|
||||
zoomDepth = 1,
|
||||
zoomMode = "auto",
|
||||
@@ -75,13 +92,106 @@ export default function Item({
|
||||
loadingLabel,
|
||||
children,
|
||||
}: ItemProps) {
|
||||
const timeline = useTimelineContext();
|
||||
const presentation = useTimelinePresentation();
|
||||
const clipPresentation =
|
||||
variant === "clip" ? undefined : (suppliedPresentation ?? presentation.clips);
|
||||
const displaySpan = clipPresentation?.length
|
||||
? getRegionDisplaySpan(span, clipPresentation)
|
||||
: (suppliedDisplaySpan ?? span);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const targets = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
presentation.regions
|
||||
.filter((region) => region.id !== id)
|
||||
.flatMap((region) => [region.start, region.end]),
|
||||
),
|
||||
],
|
||||
[presentation.regions, id],
|
||||
);
|
||||
const snap = (next: Span, edge?: "start" | "end") =>
|
||||
snapRegionSpan(next, targets, presentation.clips, timeline.pixelsToValue(1), edge);
|
||||
const getMediaSpanFromDrag: GetSpanFromDragEvent = (event) => {
|
||||
if (!("delta" in event)) return span;
|
||||
const dragged = timeline.getSpanFromDragEvent(event);
|
||||
if (!dragged) return null;
|
||||
if (clipPresentation) {
|
||||
const start = getTimeAtClipSeam(dragged.start, clipPresentation);
|
||||
return snap({ start, end: start + span.end - span.start });
|
||||
}
|
||||
const visualOffset = displaySpan.start - span.start;
|
||||
return { start: dragged.start - visualOffset, end: dragged.end - visualOffset };
|
||||
};
|
||||
const getMediaSpanFromResize: GetSpanFromResizeEvent = (event) => {
|
||||
const delta = timeline.pixelsToValue(event.delta.x);
|
||||
const edge = event.direction;
|
||||
if (clipPresentation)
|
||||
return snap(
|
||||
{
|
||||
...span,
|
||||
[edge]: getTimeAtClipSeam(displaySpan[edge] + delta, clipPresentation),
|
||||
},
|
||||
edge,
|
||||
);
|
||||
const scale = (span.end - span.start) / (displaySpan.end - displaySpan.start);
|
||||
return { ...span, [edge]: span[edge] + delta * scale };
|
||||
};
|
||||
|
||||
const paintPreview = (preview: Span, deltaY = 0) => {
|
||||
const node = nodeRef.current;
|
||||
if (!node || !clipPresentation) return;
|
||||
const display = getRegionDisplaySpan(preview, clipPresentation);
|
||||
const side = timeline.direction === "rtl" ? "right" : "left";
|
||||
node.style[side] = `${timeline.valueToPixels(display.start - timeline.range.start)}px`;
|
||||
node.style.width = `${timeline.valueToPixels(display.end - display.start)}px`;
|
||||
node.style.transform = deltaY ? `translateY(${deltaY}px)` : "none";
|
||||
};
|
||||
const { previewConfig } = presentation;
|
||||
useDndMonitor({
|
||||
onDragMove(event) {
|
||||
if (event.active.id !== id || !clipPresentation) return;
|
||||
const next = getMediaSpanFromDrag(event);
|
||||
if (next) {
|
||||
const resolved = resolveDragEnd(id, next, rowId, previewConfig);
|
||||
paintPreview(resolved?.span ?? span, event.delta.y);
|
||||
}
|
||||
},
|
||||
onDragEnd(event) {
|
||||
if (event.active.id === id) paintPreview(span);
|
||||
},
|
||||
onDragCancel(event) {
|
||||
if (event.active.id === id) paintPreview(span);
|
||||
},
|
||||
});
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
span: displaySpan,
|
||||
disabled: disabled || isLoading,
|
||||
data: { rowId },
|
||||
data: {
|
||||
rowId,
|
||||
span,
|
||||
getSpanFromDragEvent: getMediaSpanFromDrag,
|
||||
getSpanFromResizeEvent: getMediaSpanFromResize,
|
||||
},
|
||||
resizeHandleWidth: variant === "clip" ? 12 : undefined,
|
||||
onResizeMove(event) {
|
||||
if (!clipPresentation) return;
|
||||
const next = getMediaSpanFromResize(event);
|
||||
if (next) {
|
||||
const resolved = resolveResizeEnd(id, next, previewConfig);
|
||||
if (resolved) paintPreview(resolved);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const attachRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
nodeRef.current = node;
|
||||
setNodeRef(node);
|
||||
},
|
||||
[setNodeRef],
|
||||
);
|
||||
const timeLabel = useMemo(
|
||||
() => `${formatPlayheadTime(span.start)} – ${formatPlayheadTime(span.end)}`,
|
||||
[span.start, span.end],
|
||||
@@ -90,7 +200,7 @@ export default function Item({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={attachRef}
|
||||
style={{
|
||||
...itemStyle,
|
||||
height: "100%",
|
||||
@@ -101,6 +211,8 @@ export default function Item({
|
||||
{...attributes}
|
||||
data-timeline-item="true"
|
||||
data-variant={variant}
|
||||
data-start-ms={span.start}
|
||||
data-end-ms={span.end}
|
||||
onMouseDownCapture={(event) => event.stopPropagation()}
|
||||
onClickCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -146,29 +258,38 @@ export default function Item({
|
||||
...itemStyle,
|
||||
minWidth: MIN_ITEM_PX,
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overflow: isClip ? "visible" : "hidden",
|
||||
pointerEvents: "auto" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={attachRef}
|
||||
style={safeItemStyle}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
data-timeline-item="true"
|
||||
data-variant={variant}
|
||||
data-start-ms={span.start}
|
||||
data-end-ms={span.end}
|
||||
aria-label={
|
||||
isClip
|
||||
? `Clip${clipSpeedLabel ? ` ${clipSpeedLabel}` : ""} · ${timeLabel}`
|
||||
: undefined
|
||||
}
|
||||
onPointerDownCapture={handleSelect}
|
||||
onDoubleClick={(event) => {
|
||||
if (!onDoubleClick) return;
|
||||
event.stopPropagation();
|
||||
onDoubleClick();
|
||||
}}
|
||||
className="group h-full"
|
||||
>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
...itemContentStyle,
|
||||
overflow: isClip ? "visible" : "hidden",
|
||||
minWidth: MIN_ITEM_PX,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
@@ -178,14 +299,16 @@ export default function Item({
|
||||
<div
|
||||
className={cn(
|
||||
glassClass,
|
||||
"timeline-block w-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
|
||||
"timeline-block w-full flex items-center justify-center gap-1.5 relative",
|
||||
isSelected && glassStyles.selected,
|
||||
embedded && glassStyles.embeddedCaption,
|
||||
isClip ? "overflow-visible" : "overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
height: "85%",
|
||||
minHeight: 22,
|
||||
minHeight: embedded ? 18 : 22,
|
||||
minWidth: MIN_ITEM_PX,
|
||||
containerType: "inline-size",
|
||||
containerType: "size",
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -204,7 +327,11 @@ export default function Item({
|
||||
glassStyles.left,
|
||||
isClip && glassStyles.clipHandle,
|
||||
)}
|
||||
style={{ cursor: "col-resize", pointerEvents: "auto" }}
|
||||
style={{
|
||||
cursor: "col-resize",
|
||||
pointerEvents: isClip ? "none" : "auto",
|
||||
display: isClip && sharedLeftGrip ? "none" : undefined,
|
||||
}}
|
||||
title="Resize left"
|
||||
/>
|
||||
<div
|
||||
@@ -213,7 +340,11 @@ export default function Item({
|
||||
glassStyles.right,
|
||||
isClip && glassStyles.clipHandle,
|
||||
)}
|
||||
style={{ cursor: "col-resize", pointerEvents: "auto" }}
|
||||
style={{
|
||||
cursor: "col-resize",
|
||||
pointerEvents: isClip ? "none" : "auto",
|
||||
display: isClip && sharedRightGrip ? "none" : undefined,
|
||||
}}
|
||||
title="Resize right"
|
||||
/>
|
||||
{showAudioWaveform && waveformPeaks && (
|
||||
@@ -240,21 +371,27 @@ export default function Item({
|
||||
"relative z-10 flex max-w-full items-center justify-center gap-1 px-1 text-[11px] font-medium text-black/70 dark:text-white/90 select-none overflow-hidden",
|
||||
isClip &&
|
||||
"rounded bg-black/65 px-2 py-1 text-white dark:text-white",
|
||||
isZoom && "text-white dark:text-white",
|
||||
(isZoom || embedded) && "text-white dark:text-white",
|
||||
embedded && "w-full justify-start px-2",
|
||||
)}
|
||||
>
|
||||
{isClip ? (
|
||||
clipSpeedLabel
|
||||
) : isZoom ? (
|
||||
<>
|
||||
<ZoomIn className="zoom-icon size-3 shrink-0" />
|
||||
<ZoomIn
|
||||
aria-hidden="true"
|
||||
className="zoom-icon size-3 shrink-0"
|
||||
/>
|
||||
<span className="zoom-value whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
<span className="zoom-mode ml-1 font-normal">
|
||||
<span className="zoom-mode font-normal">
|
||||
{zoomMode === "manual" ? "Manual" : "Auto"}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : embedded ? (
|
||||
<span className="truncate">{children}</span>
|
||||
) : (
|
||||
<>
|
||||
{isTrim ? (
|
||||
|
||||
@@ -192,99 +192,99 @@
|
||||
|
||||
/* --- Light-mode overrides --- */
|
||||
:global(:root:not(.dark)) .glassGreen {
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
|
||||
background: #93c5fd;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassGreen:hover {
|
||||
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
|
||||
background: #60a5fa;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassGreen.selected {
|
||||
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
|
||||
background: #3b82f6;
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassRed {
|
||||
background: linear-gradient(180deg, #fee2e2 0%, #fca5a5 100%);
|
||||
background: #fca5a5;
|
||||
border-color: #f87171;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassRed:hover {
|
||||
background: linear-gradient(180deg, #fecaca 0%, #f87171 100%);
|
||||
background: #f87171;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassRed.selected {
|
||||
background: linear-gradient(180deg, #fca5a5 0%, #ef4444 100%);
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
box-shadow: inset 0 0 0 1.5px #ef4444;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassYellow {
|
||||
background: linear-gradient(180deg, #fef9c3 0%, #fde047 100%);
|
||||
background: #fde047;
|
||||
border-color: #facc15;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassYellow:hover {
|
||||
background: linear-gradient(180deg, #fef08a 0%, #facc15 100%);
|
||||
background: #facc15;
|
||||
border-color: #eab308;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassYellow.selected {
|
||||
background: linear-gradient(180deg, #fde047 0%, #eab308 100%);
|
||||
background: #eab308;
|
||||
border-color: #b4a046;
|
||||
box-shadow: inset 0 0 0 1.5px #b4a046;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassAmber {
|
||||
background: linear-gradient(180deg, #fef3c7 0%, #fcd34d 100%);
|
||||
background: #fcd34d;
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassAmber:hover {
|
||||
background: linear-gradient(180deg, #fde68a 0%, #fbbf24 100%);
|
||||
background: #fbbf24;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassAmber.selected {
|
||||
background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%);
|
||||
background: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: inset 0 0 0 1.5px #f59e0b;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassCyan {
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%);
|
||||
background: #93c5fd;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCyan:hover {
|
||||
background: linear-gradient(180deg, #bfdbfe 0%, #60a5fa 100%);
|
||||
background: #60a5fa;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCyan.selected {
|
||||
background: linear-gradient(180deg, #93c5fd 0%, #3b82f6 100%);
|
||||
background: #3b82f6;
|
||||
border-color: #2563eb;
|
||||
box-shadow: inset 0 0 0 1.5px #2563eb;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassDarkGreen {
|
||||
background: linear-gradient(180deg, #dcfce7 0%, #86efac 100%);
|
||||
background: #86efac;
|
||||
border-color: #4ade80;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassDarkGreen:hover {
|
||||
background: linear-gradient(180deg, #bbf7d0 0%, #4ade80 100%);
|
||||
background: #4ade80;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassDarkGreen.selected {
|
||||
background: linear-gradient(180deg, #86efac 0%, #22c55e 100%);
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
box-shadow: inset 0 0 0 1.5px #22c55e;
|
||||
}
|
||||
|
||||
:global(:root:not(.dark)) .glassCaption {
|
||||
background: linear-gradient(180deg, #fce7f3 0%, #f9a8d4 100%);
|
||||
background: #f9a8d4;
|
||||
border-color: #f472b6;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCaption:hover {
|
||||
background: linear-gradient(180deg, #fbcfe8 0%, #f472b6 100%);
|
||||
background: #f472b6;
|
||||
border-color: #ec4899;
|
||||
}
|
||||
:global(:root:not(.dark)) .glassCaption.selected {
|
||||
background: linear-gradient(180deg, #f9a8d4 0%, #ec4899 100%);
|
||||
background: #ec4899;
|
||||
border-color: #ec4899;
|
||||
box-shadow: inset 0 0 0 1.5px #ec4899;
|
||||
}
|
||||
@@ -331,3 +331,49 @@
|
||||
.zoomEndCap.clipHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Clip grips sit outside the filmstrip; the clip boundary is the trim target. */
|
||||
.zoomEndCap.clipHandle.left {
|
||||
left: -9px;
|
||||
}
|
||||
.zoomEndCap.clipHandle.right {
|
||||
right: -9px;
|
||||
}
|
||||
|
||||
/* Caption previews share the filmstrip, with enough contrast over any footage. */
|
||||
.glassCaption.embeddedCaption,
|
||||
:global(:root:not(.dark)) .glassCaption.embeddedCaption {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.glassCaption.embeddedCaption.selected {
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Caption selection uses the same primary blue as the editor controls. */
|
||||
.glassCaption.selected,
|
||||
:global(:root:not(.dark)) .glassCaption.selected,
|
||||
.glassCaption.embeddedCaption.selected,
|
||||
:global(:root:not(.dark)) .glassCaption.embeddedCaption.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1.5px var(--accent);
|
||||
}
|
||||
|
||||
/* Compact caption lane above the filmstrip. */
|
||||
.glassCaption,
|
||||
.glassCaption.embeddedCaption,
|
||||
:global(:root:not(.dark)) .glassCaption,
|
||||
:global(:root:not(.dark)) .glassCaption.embeddedCaption {
|
||||
background: #b58b12;
|
||||
border-color: #facc15;
|
||||
}
|
||||
.glassCaption:hover,
|
||||
.glassCaption.embeddedCaption:hover {
|
||||
background: #c49a18;
|
||||
}
|
||||
.glassCaption.selected,
|
||||
:global(:root:not(.dark)) .glassCaption.selected {
|
||||
border-color: #fde047;
|
||||
box-shadow: inset 0 0 0 1.5px #fde047;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { RowDefinition } from "dnd-timeline";
|
||||
import { useRow } from "dnd-timeline";
|
||||
import { TIMELINE_CLIP_ROW_HEIGHT_PX } from "./timelineLayout";
|
||||
import { TIMELINE_CLIP_ROW_HEIGHT_PX, TIMELINE_ROW_MIN_HEIGHT_PX } from "./timelineLayout";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
embedded?: boolean;
|
||||
caption?: boolean;
|
||||
compact?: boolean;
|
||||
filmstrip?: boolean;
|
||||
children: React.ReactNode;
|
||||
@@ -19,7 +21,9 @@ interface RowProps extends RowDefinition {
|
||||
|
||||
export default function Row({
|
||||
id,
|
||||
compact = false,
|
||||
embedded = false,
|
||||
caption = false,
|
||||
compact = true,
|
||||
filmstrip = false,
|
||||
children,
|
||||
label,
|
||||
@@ -36,13 +40,33 @@ export default function Row({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-transparent relative flex-1 min-h-[26px]"
|
||||
data-timeline-row={id}
|
||||
className="bg-transparent relative"
|
||||
style={{
|
||||
...rowWrapperStyle,
|
||||
...(embedded || caption
|
||||
? {
|
||||
position: "absolute" as const,
|
||||
insetInline: 0,
|
||||
...(caption ? { top: -20 } : { bottom: 7 }),
|
||||
zIndex: 15,
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
marginBottom: 2,
|
||||
flexGrow: compact || filmstrip ? 0 : 1,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
flexBasis: filmstrip ? TIMELINE_CLIP_ROW_HEIGHT_PX : compact ? 32 : undefined,
|
||||
height:
|
||||
embedded || caption
|
||||
? 20
|
||||
: filmstrip || !compact
|
||||
? TIMELINE_CLIP_ROW_HEIGHT_PX
|
||||
: TIMELINE_ROW_MIN_HEIGHT_PX,
|
||||
flexBasis: caption
|
||||
? 20
|
||||
: filmstrip || !compact
|
||||
? TIMELINE_CLIP_ROW_HEIGHT_PX
|
||||
: TIMELINE_ROW_MIN_HEIGHT_PX,
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
@@ -60,7 +84,7 @@ export default function Row({
|
||||
)}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="relative h-full min-h-[26px] overflow-hidden"
|
||||
className="relative min-w-0 self-stretch"
|
||||
style={rowStyle}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseMove={onMouseMove}
|
||||
|
||||
@@ -23,6 +23,7 @@ import KeyframeMarkers from "./components/markers/KeyframeMarkers";
|
||||
import TimelineCanvas from "./components/viewport/TimelineCanvas";
|
||||
import TimelineWrapper from "./components/wrapper/TimelineWrapper";
|
||||
import { calculateTimelineScale } from "./core/time";
|
||||
import type { ClipSequenceSpan } from "./core/timelineTypes";
|
||||
import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks";
|
||||
import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime";
|
||||
import { useTimelineRange } from "./hooks/useTimelineRange";
|
||||
@@ -51,7 +52,7 @@ export interface TimelineEditorProps {
|
||||
onTrimSpanChange?: (id: string, span: Span) => void;
|
||||
clipRegions?: ClipRegion[];
|
||||
onClipSplit?: (splitMs: number) => void;
|
||||
onClipSpanChange?: (id: string, span: Span) => void;
|
||||
onClipSpanChange?: (id: string, span: ClipSequenceSpan) => void;
|
||||
onClipDelete?: (id: string) => void;
|
||||
selectedClipId?: string | null;
|
||||
onSelectClip?: (id: string | null) => void;
|
||||
@@ -422,7 +423,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-transparent overflow-hidden">
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 min-h-0 overflow-auto bg-transparent relative"
|
||||
data-testid="timeline-scroll"
|
||||
style={{ outline: "none", boxShadow: "none" }}
|
||||
className="flex-1 min-h-0 overflow-x-hidden overflow-y-auto bg-transparent relative px-3"
|
||||
tabIndex={0}
|
||||
onFocus={() => {
|
||||
isTimelineFocusedRef.current = true;
|
||||
@@ -430,8 +433,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
onBlur={() => {
|
||||
isTimelineFocusedRef.current = false;
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
timelineContainerRef.current?.focus();
|
||||
onPointerDownCapture={() => {
|
||||
timelineContainerRef.current?.focus({ preventScroll: true });
|
||||
isTimelineFocusedRef.current = true;
|
||||
}}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { calculateAxisScale, formatTimeLabel } from "../../core/time";
|
||||
|
||||
interface TimelineAxisProps {
|
||||
videoDurationMs: number;
|
||||
currentTimeMs: number;
|
||||
}
|
||||
|
||||
export default function TimelineAxis({ videoDurationMs, currentTimeMs }: TimelineAxisProps) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
const { intervalMs } = useMemo(
|
||||
() => calculateAxisScale(range.end - range.start, valueToPixels(range.end - range.start)),
|
||||
[range.end, range.start, valueToPixels],
|
||||
);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (intervalMs <= 0) {
|
||||
return { markers: [], minorTicks: [] as number[] };
|
||||
}
|
||||
|
||||
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
||||
const visibleStart = Math.max(0, Math.min(range.start, maxTime));
|
||||
const visibleEnd = Math.min(range.end, maxTime);
|
||||
const markerTimes = new Set<number>();
|
||||
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
||||
|
||||
for (let time = firstMarker; time <= visibleEnd; time += intervalMs) {
|
||||
markerTimes.add(Math.round(time));
|
||||
}
|
||||
|
||||
const sorted = Array.from(markerTimes)
|
||||
.filter((time) => time >= visibleStart && time <= visibleEnd)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const minorTicks: number[] = [];
|
||||
const minorInterval = intervalMs / 5;
|
||||
for (let time = firstMarker; time <= visibleEnd; time += minorInterval) {
|
||||
const isMajor = Math.abs(time % intervalMs) < 1;
|
||||
if (!isMajor) minorTicks.push(time);
|
||||
}
|
||||
|
||||
return {
|
||||
markers: sorted.map((time) => ({ time, label: formatTimeLabel(time, intervalMs) })),
|
||||
minorTicks,
|
||||
};
|
||||
}, [intervalMs, range.end, range.start, videoDurationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="timeline-axis h-8 bg-editor-bg relative overflow-hidden select-none"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
>
|
||||
{markers.minorTicks.map((time) => {
|
||||
const offset = valueToPixels(time - range.start);
|
||||
return (
|
||||
<div
|
||||
key={`minor-${time}`}
|
||||
className="absolute bottom-1 h-1 w-[1px] bg-foreground/5"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{markers.markers.map((marker) => {
|
||||
const offset = valueToPixels(marker.time - range.start);
|
||||
const markerStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
[sideProperty]: `${offset}px`,
|
||||
transform:
|
||||
offset < 30
|
||||
? "none"
|
||||
: offset > valueToPixels(range.end - range.start) - 30
|
||||
? direction === "rtl"
|
||||
? "translateX(100%)"
|
||||
: "translateX(-100%)"
|
||||
: direction === "rtl"
|
||||
? "translateX(50%)"
|
||||
: "translateX(-50%)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div className="flex flex-col items-center pb-1">
|
||||
<div className="mb-1.5 h-[5px] w-[5px] rounded-full bg-foreground/30" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium tabular-nums tracking-tight",
|
||||
Math.abs(marker.time - currentTimeMs) < 1
|
||||
? "text-red-500"
|
||||
: "text-foreground/40",
|
||||
)}
|
||||
>
|
||||
{marker.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function ClipFilmstrip({
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="clip-filmstrip"
|
||||
className="pointer-events-none absolute inset-0 flex overflow-hidden"
|
||||
className="pointer-events-none absolute inset-0 flex overflow-hidden rounded-[inherit]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{loading && <Skeleton className="h-full w-full rounded-none" />}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTimelinePresentation } from "../../core/TimelinePresentation";
|
||||
import { getPlayheadDisplayTime, getTimeAtClipSeam } from "../../core/clipPresentation";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
@@ -24,6 +26,7 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
|
||||
timelineRef,
|
||||
}) => {
|
||||
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const { clips } = useTimelinePresentation();
|
||||
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +41,7 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
// Update the keyframe position in real-time
|
||||
onKeyframeMove(draggingKeyframeId, absoluteMs);
|
||||
onKeyframeMove(draggingKeyframeId, getTimeAtClipSeam(absoluteMs, clips));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -56,6 +59,7 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}, [
|
||||
clips,
|
||||
draggingKeyframeId,
|
||||
onKeyframeMove,
|
||||
timelineRef,
|
||||
@@ -68,7 +72,7 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
|
||||
return (
|
||||
<>
|
||||
{keyframes.map((kf) => {
|
||||
const offset = valueToPixels(kf.time - range.start);
|
||||
const offset = valueToPixels(getPlayheadDisplayTime(kf.time, clips) - range.start);
|
||||
const isSelected = kf.id === selectedKeyframeId;
|
||||
const isDragging = kf.id === draggingKeyframeId;
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { memo, useMemo } from "react";
|
||||
import { calculateAxisScale } from "../../core/time";
|
||||
|
||||
interface ClipMarkerOverlayProps {
|
||||
videoDurationMs: number;
|
||||
}
|
||||
|
||||
function ClipMarkerOverlayComponent({ videoDurationMs }: ClipMarkerOverlayProps) {
|
||||
const { direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
const { intervalMs } = useMemo(
|
||||
() => calculateAxisScale(range.end - range.start),
|
||||
[range.end, range.start],
|
||||
);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (intervalMs <= 0) return [] as { time: number; offset: number }[];
|
||||
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
||||
const visibleStart = Math.max(0, range.start);
|
||||
const visibleEnd = Math.min(range.end, maxTime);
|
||||
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
||||
const result: { time: number; offset: number }[] = [];
|
||||
for (let time = firstMarker; time <= maxTime; time += intervalMs) {
|
||||
if (time > visibleStart && time < visibleEnd) {
|
||||
result.push({
|
||||
time: Math.round(time),
|
||||
offset: valueToPixels(Math.round(time) - range.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [intervalMs, range.start, range.end, videoDurationMs, valueToPixels]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1]">
|
||||
{markers.map(({ time, offset }) => (
|
||||
<div
|
||||
key={time}
|
||||
className="absolute w-px"
|
||||
style={{
|
||||
top: "7.5%",
|
||||
bottom: "7.5%",
|
||||
[sideProperty]: `${offset}px`,
|
||||
background:
|
||||
"linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ClipMarkerOverlayComponent);
|
||||
@@ -3,7 +3,14 @@ import { useEffect, useState, type RefObject } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatPlayheadTime } from "../../core/time";
|
||||
|
||||
import {
|
||||
getPlayheadDisplayTime,
|
||||
getTimeAtClipSeam,
|
||||
type ClipPresentation,
|
||||
} from "../../core/clipPresentation";
|
||||
|
||||
interface PlaybackCursorProps {
|
||||
clips: ClipPresentation[];
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
@@ -13,6 +20,7 @@ interface PlaybackCursorProps {
|
||||
}
|
||||
|
||||
export default function PlaybackCursor({
|
||||
clips,
|
||||
currentTimeMs,
|
||||
videoDurationMs,
|
||||
onSeek,
|
||||
@@ -23,6 +31,7 @@ export default function PlaybackCursor({
|
||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
@@ -35,7 +44,10 @@ export default function PlaybackCursor({
|
||||
? rect.right - sidebarWidth - e.clientX
|
||||
: e.clientX - rect.left - sidebarWidth;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
let absoluteMs = getTimeAtClipSeam(
|
||||
Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)),
|
||||
clips,
|
||||
);
|
||||
|
||||
const snapThresholdMs = 150;
|
||||
const nearbyKeyframe = keyframes.find(
|
||||
@@ -65,6 +77,7 @@ export default function PlaybackCursor({
|
||||
};
|
||||
}, [
|
||||
isDragging,
|
||||
clips,
|
||||
onSeek,
|
||||
timelineRef,
|
||||
sidebarWidth,
|
||||
@@ -80,62 +93,49 @@ export default function PlaybackCursor({
|
||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
||||
if (clampedTime < range.start || clampedTime > range.end) return null;
|
||||
|
||||
const offset = valueToPixels(clampedTime - range.start);
|
||||
const offset = valueToPixels(getPlayheadDisplayTime(clampedTime, clips) - range.start);
|
||||
|
||||
const expanded = isHovered || isDragging || isLoading;
|
||||
// Keep the timestamp inside the viewport even at either end of the timeline.
|
||||
const width = valueToPixels(range.end - range.start);
|
||||
const capShift = Math.max(0, 34 - offset) - Math.max(0, 34 - (width - offset));
|
||||
return (
|
||||
<div
|
||||
data-testid="timeline-playhead"
|
||||
className="absolute top-0 bottom-0 z-50 group/cursor"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div data-testid="timeline-playhead" className="absolute inset-0 z-50 pointer-events-none">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-white shadow-[0_0_0_1px_#ef4444] cursor-ew-resize pointer-events-auto"
|
||||
style={{ [sideProperty]: `${offset}px` }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
className="absolute top-0 bottom-0 w-px bg-red-500"
|
||||
style={{ [sideProperty]: sidebarWidth + offset }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
|
||||
style={{ width: "16px", height: "16px" }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Playhead ${formatPlayheadTime(clampedTime)}`}
|
||||
data-testid="playhead-cap"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsHovered(true)}
|
||||
onBlur={() => setIsHovered(false)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
className="absolute top-0 h-4 rounded-full bg-red-500 text-white cursor-ew-resize pointer-events-auto overflow-hidden transition-[width,transform] duration-150"
|
||||
style={{
|
||||
width: expanded ? 68 : 16,
|
||||
left: "50%",
|
||||
transform: `translateX(calc(-50% + ${expanded ? (direction === "rtl" ? -capShift : capShift) : 0}px))`,
|
||||
}}
|
||||
>
|
||||
<div className="w-3 h-3 mx-auto mt-[2px] bg-red-500 rotate-45 rounded-sm" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none transition-opacity",
|
||||
isDragging || isLoading ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{formatPlayheadTime(clampedTime)
|
||||
.split("")
|
||||
.map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"leading-5 whitespace-pre",
|
||||
isLoading &&
|
||||
"bg-gradient-to-r from-white/40 via-white to-white/40 bg-clip-text text-transparent animate-text-shimmer",
|
||||
)}
|
||||
style={
|
||||
isLoading
|
||||
? {
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
animationDuration: "2.5s",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"block whitespace-nowrap text-[10px] font-medium tabular-nums transition-opacity",
|
||||
expanded ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{formatPlayheadTime(clampedTime)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Check,
|
||||
CaretDown as ChevronDown,
|
||||
Crop,
|
||||
ChatText as MessageSquare,
|
||||
MusicNote as Music,
|
||||
Scissors,
|
||||
MagicWand as WandSparkles,
|
||||
MagnifyingGlassPlus as ZoomIn,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
type AspectRatio,
|
||||
getAspectRatioLabel,
|
||||
isCustomAspectRatio,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
aspectRatio: AspectRatio;
|
||||
isCropped: boolean;
|
||||
scrollLabels: { pan: string; zoom: string };
|
||||
customAspectWidth: string;
|
||||
customAspectHeight: string;
|
||||
onCustomAspectWidthChange: (value: string) => void;
|
||||
onCustomAspectHeightChange: (value: string) => void;
|
||||
onCustomAspectRatioKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
||||
onApplyCustomAspectRatio: () => void;
|
||||
onAspectRatioChange?: (aspectRatio: AspectRatio) => void;
|
||||
onOpenCropEditor?: () => void;
|
||||
onAddZoom: () => void;
|
||||
onSuggestZooms: () => void;
|
||||
onAddAnnotation: () => void;
|
||||
onAddAudio: () => void;
|
||||
onSplitClip: () => void;
|
||||
cropLabel: string;
|
||||
addZoomLabel: string;
|
||||
suggestZoomsLabel: string;
|
||||
addAnnotationLabel: string;
|
||||
addAudioLabel: string;
|
||||
splitClipLabel: string;
|
||||
}
|
||||
|
||||
export default function TimelineToolbar({
|
||||
aspectRatio,
|
||||
isCropped,
|
||||
scrollLabels,
|
||||
customAspectWidth,
|
||||
customAspectHeight,
|
||||
onCustomAspectWidthChange,
|
||||
onCustomAspectHeightChange,
|
||||
onCustomAspectRatioKeyDown,
|
||||
onApplyCustomAspectRatio,
|
||||
onAspectRatioChange,
|
||||
onOpenCropEditor,
|
||||
onAddZoom,
|
||||
onSuggestZooms,
|
||||
onAddAnnotation,
|
||||
onAddAudio,
|
||||
onSplitClip,
|
||||
cropLabel,
|
||||
addZoomLabel,
|
||||
suggestZoomsLabel,
|
||||
addAnnotationLabel,
|
||||
addAudioLabel,
|
||||
splitClipLabel,
|
||||
}: TimelineToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-foreground/10 bg-editor-panel">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onAddZoom}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={addZoomLabel}
|
||||
aria-label={addZoomLabel}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSuggestZooms}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={suggestZoomsLabel}
|
||||
aria-label={suggestZoomsLabel}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onAddAnnotation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={addAnnotationLabel}
|
||||
aria-label={addAnnotationLabel}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onAddAudio}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={addAudioLabel}
|
||||
aria-label={addAudioLabel}
|
||||
>
|
||||
<Music className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSplitClip}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={splitClipLabel}
|
||||
aria-label={splitClipLabel}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1">
|
||||
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
{ASPECT_RATIOS.map((ratio) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
key={ratio}
|
||||
onClick={() => onAspectRatioChange?.(ratio)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3"
|
||||
>
|
||||
<span>{getAspectRatioLabel(ratio)}</span>
|
||||
{aspectRatio === ratio && (
|
||||
<Check className="w-3 h-3 text-[#2563EB]" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<div className="mx-1 my-1 h-px bg-foreground/10" />
|
||||
<div className="px-2 py-1.5 flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-sm">Custom</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAspectWidth}
|
||||
onChange={(event) =>
|
||||
onCustomAspectWidthChange(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
onKeyDown={onCustomAspectRatioKeyDown}
|
||||
className="w-12 h-7 px-1.5 text-sm"
|
||||
aria-label="Custom aspect width"
|
||||
/>
|
||||
<span className="text-muted-foreground/70">:</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={customAspectHeight}
|
||||
onChange={(event) =>
|
||||
onCustomAspectHeightChange(
|
||||
event.target.value.replace(/\D/g, ""),
|
||||
)
|
||||
}
|
||||
onKeyDown={onCustomAspectRatioKeyDown}
|
||||
className="w-12 h-7 px-1.5 text-sm"
|
||||
aria-label="Custom aspect height"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onApplyCustomAspectRatio}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
{isCustomAspectRatio(aspectRatio) && (
|
||||
<Check className="w-3 h-3 text-[#2563EB] ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="w-[1px] h-4 bg-foreground/10" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenCropEditor}
|
||||
disabled={!onOpenCropEditor}
|
||||
className="h-7 px-2 text-xs gap-1.5"
|
||||
>
|
||||
<Crop className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{cropLabel}</span>
|
||||
{isCropped ? <span className="h-1.5 w-1.5 rounded-full bg-[#2563EB]" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-4 text-[10px] text-muted-foreground/70 font-medium">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">
|
||||
Side Scroll
|
||||
</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="px-1.5 py-0.5 bg-foreground/5 border border-foreground/10 rounded text-[#2563EB] font-sans">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTimelinePresentation } from "../../core/TimelinePresentation";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import {
|
||||
@@ -35,13 +36,18 @@ import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks";
|
||||
import Item from "../../Item";
|
||||
import glassStyles from "../../ItemGlass.module.css";
|
||||
import Row from "../../Row";
|
||||
import {
|
||||
type ClipPresentation,
|
||||
getEmbeddedCaptionSpan,
|
||||
getTimeAtClipSeam,
|
||||
getRegionDisplaySpan,
|
||||
getPlayheadDisplayTime,
|
||||
} from "../../core/clipPresentation";
|
||||
import {
|
||||
getTimelineContentMinHeightPx,
|
||||
getTimelineRowsMinHeightPx,
|
||||
getTimelineViewportStretchFactor,
|
||||
TIMELINE_AXIS_HEIGHT_PX,
|
||||
} from "../../timelineLayout";
|
||||
import TimelineAxis from "../axis/TimelineAxis";
|
||||
import PlaybackCursor from "../playhead/PlaybackCursor";
|
||||
|
||||
const HINT_CLIP = "Press C to split clip";
|
||||
@@ -84,6 +90,7 @@ interface TimelineCanvasProps {
|
||||
}
|
||||
|
||||
interface LaneHoverParams {
|
||||
clipPresentation?: ClipPresentation[];
|
||||
direction: string;
|
||||
rangeStart: number;
|
||||
visibleDurationMs: number;
|
||||
@@ -109,6 +116,7 @@ interface LaneHoverParams {
|
||||
* add/can-place callbacks.
|
||||
*/
|
||||
function useTimelineLaneHover({
|
||||
clipPresentation,
|
||||
direction,
|
||||
rangeStart,
|
||||
visibleDurationMs,
|
||||
@@ -133,9 +141,14 @@ function useTimelineLaneHover({
|
||||
: Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
const ratio = position / rect.width;
|
||||
const nextMs = rangeStart + ratio * visibleDurationMs;
|
||||
setHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
setHoverMs(
|
||||
getTimeAtClipSeam(
|
||||
Math.max(0, Math.min(nextMs, videoDurationMs)),
|
||||
clipPresentation ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
[direction, rangeStart, videoDurationMs, visibleDurationMs],
|
||||
[direction, rangeStart, videoDurationMs, visibleDurationMs, clipPresentation],
|
||||
);
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
@@ -167,12 +180,32 @@ function useTimelineLaneHover({
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
// Respect the lane's enabled flag so a hidden ghost can't still add on click.
|
||||
if (!enabled || !onAddAtMs || hoverMs === null) return;
|
||||
const startMs = Math.max(0, Math.min(hoverMs, videoDurationMs));
|
||||
if (!enabled || isDragging || !onAddAtMs || event.button !== 0) return;
|
||||
if ((event.target as HTMLElement).closest("[data-timeline-item]")) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const x = direction === "rtl" ? rect.right - event.clientX : event.clientX - rect.left;
|
||||
const startMs = getTimeAtClipSeam(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(rangeStart + (x / rect.width) * visibleDurationMs, videoDurationMs),
|
||||
),
|
||||
clipPresentation ?? [],
|
||||
);
|
||||
if (canPlaceAtMs && !canPlaceAtMs(startMs)) return;
|
||||
onAddAtMs(startMs);
|
||||
},
|
||||
[enabled, canPlaceAtMs, onAddAtMs, videoDurationMs, hoverMs],
|
||||
[
|
||||
clipPresentation,
|
||||
enabled,
|
||||
isDragging,
|
||||
direction,
|
||||
rangeStart,
|
||||
visibleDurationMs,
|
||||
canPlaceAtMs,
|
||||
onAddAtMs,
|
||||
videoDurationMs,
|
||||
],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
@@ -213,7 +246,6 @@ function useTimelineLaneHover({
|
||||
return {
|
||||
reset,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
canShowGhost,
|
||||
onMouseEnter,
|
||||
@@ -225,6 +257,7 @@ function useTimelineLaneHover({
|
||||
}
|
||||
|
||||
interface TimelineHoverParams {
|
||||
clipPresentation: ClipPresentation[];
|
||||
direction: string;
|
||||
sidebarWidth: number;
|
||||
rangeStart: number;
|
||||
@@ -242,6 +275,7 @@ interface TimelineHoverParams {
|
||||
}
|
||||
|
||||
function useTimelineHover({
|
||||
clipPresentation,
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart,
|
||||
@@ -272,9 +306,11 @@ function useTimelineHover({
|
||||
const clampedX = Math.max(0, Math.min(contentX, contentWidth));
|
||||
const ratio = clampedX / contentWidth;
|
||||
const nextMs = rangeStart + ratio * visibleDurationMs;
|
||||
setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
|
||||
setTimelineHoverMs(
|
||||
getTimeAtClipSeam(Math.max(0, Math.min(nextMs, videoDurationMs)), clipPresentation),
|
||||
);
|
||||
},
|
||||
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs],
|
||||
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs, clipPresentation],
|
||||
);
|
||||
|
||||
const handleTimelineMouseEnter = useCallback(
|
||||
@@ -294,6 +330,7 @@ function useTimelineHover({
|
||||
);
|
||||
|
||||
const zoom = useTimelineLaneHover({
|
||||
clipPresentation,
|
||||
direction,
|
||||
rangeStart,
|
||||
visibleDurationMs,
|
||||
@@ -307,6 +344,7 @@ function useTimelineHover({
|
||||
});
|
||||
|
||||
const caption = useTimelineLaneHover({
|
||||
clipPresentation,
|
||||
direction,
|
||||
rangeStart,
|
||||
visibleDurationMs,
|
||||
@@ -328,7 +366,14 @@ function useTimelineHover({
|
||||
}, [zoom.reset, caption.reset]);
|
||||
|
||||
const timelineGhostOffsetPx =
|
||||
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
|
||||
timelineHoverMs === null
|
||||
? 0
|
||||
: valueToPixels(
|
||||
Math.max(
|
||||
0,
|
||||
getPlayheadDisplayTime(timelineHoverMs, clipPresentation) - rangeStart,
|
||||
),
|
||||
);
|
||||
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
|
||||
|
||||
return {
|
||||
@@ -339,7 +384,6 @@ function useTimelineHover({
|
||||
handleTimelineMouseLeave,
|
||||
canShowGhostZoom: zoom.canShowGhost,
|
||||
ghostStartMs: zoom.ghostStartMs,
|
||||
ghostStartOffsetPx: zoom.ghostStartOffsetPx,
|
||||
ghostWidthPx: zoom.ghostWidthPx,
|
||||
handleZoomRowMouseEnter: zoom.onMouseEnter,
|
||||
handleZoomRowMouseMove: zoom.onMouseMove,
|
||||
@@ -348,7 +392,6 @@ function useTimelineHover({
|
||||
handleZoomRowClick: zoom.onClick,
|
||||
canShowGhostCaption: caption.canShowGhost,
|
||||
captionGhostStartMs: caption.ghostStartMs,
|
||||
captionGhostStartOffsetPx: caption.ghostStartOffsetPx,
|
||||
captionGhostWidthPx: caption.ghostWidthPx,
|
||||
handleCaptionRowMouseEnter: caption.onMouseEnter,
|
||||
handleCaptionRowMouseMove: caption.onMouseMove,
|
||||
@@ -381,7 +424,6 @@ interface TimelineCanvasRowsProps {
|
||||
direction: string;
|
||||
canShowGhostZoom: boolean;
|
||||
ghostStartMs: number | null;
|
||||
ghostStartOffsetPx: number;
|
||||
ghostWidthPx: number;
|
||||
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
@@ -391,7 +433,6 @@ interface TimelineCanvasRowsProps {
|
||||
captionsEnabled?: boolean;
|
||||
canShowGhostCaption: boolean;
|
||||
captionGhostStartMs: number | null;
|
||||
captionGhostStartOffsetPx: number;
|
||||
captionGhostWidthPx: number;
|
||||
onCaptionRowMouseEnter: MouseEventHandler<HTMLDivElement>;
|
||||
onCaptionRowMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
@@ -460,7 +501,6 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
direction,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
onZoomRowMouseEnter,
|
||||
onZoomRowMouseMove,
|
||||
@@ -470,7 +510,6 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
captionsEnabled = false,
|
||||
canShowGhostCaption,
|
||||
captionGhostStartMs,
|
||||
captionGhostStartOffsetPx,
|
||||
captionGhostWidthPx,
|
||||
onCaptionRowMouseEnter,
|
||||
onCaptionRowMouseMove,
|
||||
@@ -478,6 +517,11 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
onCaptionRowMouseDown,
|
||||
onCaptionRowClick,
|
||||
}: TimelineCanvasRowsProps) {
|
||||
const {
|
||||
pixelsToValue,
|
||||
valueToPixels,
|
||||
range: { start: rangeStart },
|
||||
} = useTimelineContext();
|
||||
const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]);
|
||||
const { clipItems, zoomItems, captionItems, annotationRows, audioRows } = useMemo(() => {
|
||||
const nextClipItems: TimelineRenderItem[] = [];
|
||||
@@ -536,10 +580,131 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const { clips } = useTimelinePresentation();
|
||||
const clipPresentation = useMemo(
|
||||
() =>
|
||||
clipItems.map((item) => ({
|
||||
...item,
|
||||
displaySpan: getRegionDisplaySpan(item.span, clips),
|
||||
})),
|
||||
[clipItems, clips],
|
||||
);
|
||||
|
||||
const zoomGhost =
|
||||
ghostStartMs === null
|
||||
? null
|
||||
: getRegionDisplaySpan(
|
||||
{ start: ghostStartMs, end: ghostStartMs + pixelsToValue(ghostWidthPx) },
|
||||
clipPresentation,
|
||||
);
|
||||
const zoomGhostOffsetPx = zoomGhost ? valueToPixels(zoomGhost.start - rangeStart) : 0;
|
||||
const zoomGhostWidthPx = zoomGhost ? valueToPixels(zoomGhost.end - zoomGhost.start) : 0;
|
||||
const embeddedGhost =
|
||||
captionGhostStartMs === null
|
||||
? null
|
||||
: getEmbeddedCaptionSpan(
|
||||
{
|
||||
start: captionGhostStartMs,
|
||||
end: captionGhostStartMs + pixelsToValue(captionGhostWidthPx),
|
||||
},
|
||||
clipPresentation,
|
||||
);
|
||||
const embeddedGhostOffset = embeddedGhost ? valueToPixels(embeddedGhost.start - rangeStart) : 0;
|
||||
const embeddedGhostWidth = embeddedGhost
|
||||
? valueToPixels(embeddedGhost.end - embeddedGhost.start)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(captionsEnabled || captionItems.length > 0) && (
|
||||
<Row
|
||||
id={CAPTION_ROW_ID}
|
||||
caption
|
||||
isEmpty={captionItems.length === 0}
|
||||
onMouseEnter={onCaptionRowMouseEnter}
|
||||
onMouseMove={onCaptionRowMouseMove}
|
||||
onMouseLeave={onCaptionRowMouseLeave}
|
||||
onMouseDown={onCaptionRowMouseDown}
|
||||
onClick={onCaptionRowClick}
|
||||
>
|
||||
{clipPresentation.map((clip) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
data-caption-add-target
|
||||
className="absolute inset-y-0 pointer-events-auto"
|
||||
style={{
|
||||
[direction === "rtl" ? "right" : "left"]: valueToPixels(
|
||||
clip.displaySpan.start - rangeStart,
|
||||
),
|
||||
width: valueToPixels(clip.displaySpan.end - clip.displaySpan.start),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canShowGhostCaption && embeddedGhost && (
|
||||
<div
|
||||
data-testid="timeline-add-preview"
|
||||
className="absolute inset-0 z-[3] pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[18px]"
|
||||
style={
|
||||
direction === "rtl"
|
||||
? {
|
||||
right: `${embeddedGhostOffset}px`,
|
||||
width: `${embeddedGhostWidth}px`,
|
||||
}
|
||||
: {
|
||||
left: `${embeddedGhostOffset}px`,
|
||||
width: `${embeddedGhostWidth}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
glassStyles.glassCaption,
|
||||
glassStyles.embeddedCaption,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white">
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{captionItems.map((item) => {
|
||||
const displaySpan = getEmbeddedCaptionSpan(item.span, clipPresentation);
|
||||
if (!displaySpan) return null;
|
||||
return (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedCaptionId}
|
||||
onSelectId={onSelectCaption}
|
||||
onDoubleClick={() => {
|
||||
const editor = document.querySelector<HTMLTextAreaElement>(
|
||||
"[data-caption-text-editor]",
|
||||
);
|
||||
editor?.focus();
|
||||
editor?.select();
|
||||
}}
|
||||
variant="caption"
|
||||
clipPresentation={clipPresentation}
|
||||
embedded
|
||||
displaySpan={displaySpan}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
<Row filmstrip id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}>
|
||||
{clipItems.map((item) => (
|
||||
{clipPresentation.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
@@ -548,6 +713,13 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
isSelected={item.id === selectedClipId}
|
||||
onSelectId={onSelectClip}
|
||||
variant="clip"
|
||||
displaySpan={item.displaySpan}
|
||||
sharedLeftGrip={clipPresentation.some(
|
||||
(other) => other.span.end === item.span.start,
|
||||
)}
|
||||
sharedRightGrip={clipPresentation.some(
|
||||
(other) => other.span.start === item.span.end,
|
||||
)}
|
||||
videoPath={videoPath}
|
||||
sourceSpan={item.sourceSpan ?? item.span}
|
||||
speedValue={item.speedValue}
|
||||
@@ -555,6 +727,27 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
{clipPresentation.map((left) => {
|
||||
const right = clipPresentation.find(
|
||||
(clip) => clip.span.start === left.span.end,
|
||||
);
|
||||
if (!right) return null;
|
||||
const seam = (left.displaySpan.end + right.displaySpan.start) / 2;
|
||||
return (
|
||||
<div
|
||||
key={`seam-${left.id}`}
|
||||
data-testid="clip-seam-grip"
|
||||
aria-hidden="true"
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.clipHandle)}
|
||||
style={{
|
||||
height: "59.5%",
|
||||
pointerEvents: "none",
|
||||
[direction === "rtl" ? "right" : "left"]:
|
||||
valueToPixels(seam - rangeStart) - 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
{showSourceAudioTrack &&
|
||||
sourceAudioTracks.map((track) => (
|
||||
@@ -590,7 +783,12 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
|
||||
<Row
|
||||
id={ZOOM_ROW_ID}
|
||||
compact
|
||||
compact={
|
||||
annotationRows.length +
|
||||
audioRows.length +
|
||||
(showSourceAudioTrack ? sourceAudioTracks.length : 0) >
|
||||
0
|
||||
}
|
||||
isEmpty={zoomItems.length === 0}
|
||||
onMouseEnter={onZoomRowMouseEnter}
|
||||
onMouseMove={onZoomRowMouseMove}
|
||||
@@ -599,18 +797,21 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
onClick={onZoomRowClick}
|
||||
>
|
||||
{canShowGhostZoom && ghostStartMs !== null && (
|
||||
<div className="absolute inset-0 z-[3] pointer-events-none">
|
||||
<div
|
||||
data-testid="timeline-add-preview"
|
||||
className="absolute inset-0 z-[3] pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
|
||||
style={
|
||||
direction === "rtl"
|
||||
? {
|
||||
right: `${ghostStartOffsetPx}px`,
|
||||
width: `${ghostWidthPx}px`,
|
||||
right: `${zoomGhostOffsetPx}px`,
|
||||
width: `${zoomGhostWidthPx}px`,
|
||||
}
|
||||
: {
|
||||
left: `${ghostStartOffsetPx}px`,
|
||||
width: `${ghostWidthPx}px`,
|
||||
left: `${zoomGhostOffsetPx}px`,
|
||||
width: `${zoomGhostWidthPx}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -648,61 +849,6 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{(captionsEnabled || captionItems.length > 0) && (
|
||||
<Row
|
||||
id={CAPTION_ROW_ID}
|
||||
isEmpty={captionItems.length === 0}
|
||||
onMouseEnter={onCaptionRowMouseEnter}
|
||||
onMouseMove={onCaptionRowMouseMove}
|
||||
onMouseLeave={onCaptionRowMouseLeave}
|
||||
onMouseDown={onCaptionRowMouseDown}
|
||||
onClick={onCaptionRowClick}
|
||||
>
|
||||
{canShowGhostCaption && captionGhostStartMs !== null && (
|
||||
<div className="absolute inset-0 z-[3] pointer-events-none">
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]"
|
||||
style={
|
||||
direction === "rtl"
|
||||
? {
|
||||
right: `${captionGhostStartOffsetPx}px`,
|
||||
width: `${captionGhostWidthPx}px`,
|
||||
}
|
||||
: {
|
||||
left: `${captionGhostStartOffsetPx}px`,
|
||||
width: `${captionGhostWidthPx}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
glassStyles.glassCaption,
|
||||
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/45 bg-white/15 text-white">
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{captionItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedCaptionId}
|
||||
onSelectId={onSelectCaption}
|
||||
variant="caption"
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{annotationRows.map(({ rowId, items: rowItems }, index) => (
|
||||
<Row
|
||||
key={rowId}
|
||||
@@ -785,6 +931,7 @@ export default function TimelineCanvas({
|
||||
}: TimelineCanvasProps) {
|
||||
const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } =
|
||||
useTimelineContext();
|
||||
const { clips: clipPresentation } = useTimelinePresentation();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const seekRafRef = useRef<number | null>(null);
|
||||
@@ -800,7 +947,7 @@ export default function TimelineCanvas({
|
||||
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (isSeeking) return;
|
||||
if (isSeeking || (e.target as Element).closest("[data-timeline-item]")) return;
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
|
||||
if (onClearBlockSelection) {
|
||||
@@ -821,10 +968,11 @@ export default function TimelineCanvas({
|
||||
if (clickX < 0) return;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
onSeek(absoluteMs / 1000);
|
||||
onSeek(getTimeAtClipSeam(absoluteMs, clipPresentation) / 1000);
|
||||
},
|
||||
[
|
||||
isSeeking,
|
||||
clipPresentation,
|
||||
onSeek,
|
||||
onSelectZoom,
|
||||
onSelectClip,
|
||||
@@ -847,9 +995,12 @@ export default function TimelineCanvas({
|
||||
? rect.right - sidebarWidth - clientX
|
||||
: clientX - rect.left - sidebarWidth;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
return Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
return getTimeAtClipSeam(
|
||||
Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)),
|
||||
clipPresentation,
|
||||
);
|
||||
},
|
||||
[direction, pixelsToValue, range.start, sidebarWidth, videoDurationMs],
|
||||
[direction, pixelsToValue, range.start, sidebarWidth, videoDurationMs, clipPresentation],
|
||||
);
|
||||
|
||||
const handleTimelineMouseDown = useCallback(
|
||||
@@ -935,22 +1086,15 @@ export default function TimelineCanvas({
|
||||
const timelineRowCount = useMemo(() => {
|
||||
const annotationRowIds = new Set<string>();
|
||||
const audioRowIds = new Set<string>();
|
||||
let hasCaptionRow = false;
|
||||
for (const item of items) {
|
||||
if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId);
|
||||
if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId);
|
||||
if (item.rowId === CAPTION_ROW_ID) hasCaptionRow = true;
|
||||
}
|
||||
const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0;
|
||||
// The caption lane is always shown when captions are enabled (even before any cue
|
||||
// exists), so count it whenever captionsEnabled — not only when a caption item is
|
||||
// present — or the min-height/stretch math undersizes the empty lane.
|
||||
const captionRows = hasCaptionRow || captionsEnabled ? 1 : 0;
|
||||
return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size + captionRows;
|
||||
}, [items, showSourceAudioTrack, sourceAudioTracks.length, captionsEnabled]);
|
||||
return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size;
|
||||
}, [items, showSourceAudioTrack, sourceAudioTracks.length]);
|
||||
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
|
||||
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
|
||||
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const {
|
||||
canShowGhostPlayhead,
|
||||
@@ -960,7 +1104,6 @@ export default function TimelineCanvas({
|
||||
handleTimelineMouseLeave,
|
||||
canShowGhostZoom,
|
||||
ghostStartMs,
|
||||
ghostStartOffsetPx,
|
||||
ghostWidthPx,
|
||||
handleZoomRowMouseEnter,
|
||||
handleZoomRowMouseMove,
|
||||
@@ -969,7 +1112,6 @@ export default function TimelineCanvas({
|
||||
handleZoomRowClick,
|
||||
canShowGhostCaption,
|
||||
captionGhostStartMs,
|
||||
captionGhostStartOffsetPx,
|
||||
captionGhostWidthPx,
|
||||
handleCaptionRowMouseEnter,
|
||||
handleCaptionRowMouseMove,
|
||||
@@ -977,6 +1119,7 @@ export default function TimelineCanvas({
|
||||
handleCaptionRowMouseDown,
|
||||
handleCaptionRowClick,
|
||||
} = useTimelineHover({
|
||||
clipPresentation,
|
||||
direction,
|
||||
sidebarWidth,
|
||||
rangeStart: range.start,
|
||||
@@ -998,7 +1141,9 @@ export default function TimelineCanvas({
|
||||
ref={setRefs}
|
||||
style={{
|
||||
...style,
|
||||
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
|
||||
height: "100%",
|
||||
minHeight: timelineContentMinHeightPx,
|
||||
overflow: "visible",
|
||||
}}
|
||||
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
|
||||
onMouseDown={handleTimelineMouseDown}
|
||||
@@ -1007,8 +1152,9 @@ export default function TimelineCanvas({
|
||||
onMouseMove={handleTimelineMouseMove}
|
||||
onMouseLeave={handleTimelineMouseLeave}
|
||||
>
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<div aria-hidden="true" style={{ height: TIMELINE_AXIS_HEIGHT_PX, flexShrink: 0 }} />
|
||||
<PlaybackCursor
|
||||
clips={clipPresentation}
|
||||
currentTimeMs={currentTimeMs}
|
||||
videoDurationMs={videoDurationMs}
|
||||
onSeek={onSeek}
|
||||
@@ -1058,7 +1204,6 @@ export default function TimelineCanvas({
|
||||
direction={direction}
|
||||
canShowGhostZoom={canShowGhostZoom}
|
||||
ghostStartMs={ghostStartMs}
|
||||
ghostStartOffsetPx={ghostStartOffsetPx}
|
||||
ghostWidthPx={ghostWidthPx}
|
||||
onZoomRowMouseEnter={handleZoomRowMouseEnter}
|
||||
onZoomRowMouseMove={handleZoomRowMouseMove}
|
||||
@@ -1068,7 +1213,6 @@ export default function TimelineCanvas({
|
||||
captionsEnabled={captionsEnabled}
|
||||
canShowGhostCaption={canShowGhostCaption}
|
||||
captionGhostStartMs={captionGhostStartMs}
|
||||
captionGhostStartOffsetPx={captionGhostStartOffsetPx}
|
||||
captionGhostWidthPx={captionGhostWidthPx}
|
||||
onCaptionRowMouseEnter={handleCaptionRowMouseEnter}
|
||||
onCaptionRowMouseMove={handleCaptionRowMouseMove}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TimelinePresentation } from "../../core/TimelinePresentation";
|
||||
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import type {
|
||||
DragEndEvent,
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineRegionSpan } from "../../core/timelineTypes";
|
||||
import type { ClipSequenceSpan, TimelineRegionSpan } from "../../core/timelineTypes";
|
||||
import { clampRange, resolveDragEnd, resolveResizeEnd } from "../../dnd/engine";
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
@@ -23,7 +24,7 @@ interface TimelineWrapperProps {
|
||||
minItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
gridSizeMs?: number;
|
||||
onItemSpanChange: (id: string, span: Span, rowId?: string) => void;
|
||||
onItemSpanChange: (id: string, span: ClipSequenceSpan, rowId?: string) => void;
|
||||
resolveTargetRowId?: (id: string, proposedRowId: string) => string;
|
||||
allRegionSpans?: TimelineRegionSpan[];
|
||||
onLiveSpanPreviewChange?: (id: string, span: Span | null) => void;
|
||||
@@ -276,7 +277,14 @@ export default function TimelineWrapper({
|
||||
resizeHandleWidth={28}
|
||||
>
|
||||
<div className="relative h-full min-h-0">
|
||||
{children}
|
||||
<TimelinePresentation
|
||||
regions={allRegionSpans}
|
||||
totalMs={totalMs}
|
||||
minItemDurationMs={minItemDurationMs}
|
||||
hasOverlap={hasOverlap}
|
||||
>
|
||||
{children}
|
||||
</TimelinePresentation>
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { CLIP_ROW_ID } from "./constants";
|
||||
import { getClipDisplaySpan, type ClipPresentation } from "./clipPresentation";
|
||||
import type { DndEngineConfig } from "../dnd/engine";
|
||||
import type { TimelineRegionSpan } from "./timelineTypes";
|
||||
|
||||
type PreviewConfig = Pick<
|
||||
DndEngineConfig,
|
||||
"totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap"
|
||||
>;
|
||||
const Context = createContext<{
|
||||
clips: ClipPresentation[];
|
||||
regions: TimelineRegionSpan[];
|
||||
previewConfig: PreviewConfig;
|
||||
}>({
|
||||
clips: [],
|
||||
regions: [],
|
||||
previewConfig: {
|
||||
totalMs: 0,
|
||||
minItemDurationMs: 1,
|
||||
allRegionSpans: [],
|
||||
hasOverlap: () => false,
|
||||
},
|
||||
});
|
||||
export const useTimelinePresentation = () => useContext(Context);
|
||||
export function TimelinePresentation({
|
||||
regions,
|
||||
children,
|
||||
totalMs,
|
||||
minItemDurationMs,
|
||||
hasOverlap,
|
||||
}: { regions: TimelineRegionSpan[]; children: ReactNode } & Omit<PreviewConfig, "allRegionSpans">) {
|
||||
const { pixelsToValue } = useTimelineContext();
|
||||
const value = useMemo(() => {
|
||||
const spans = regions
|
||||
.filter((region) => region.rowId === CLIP_ROW_ID)
|
||||
.map(({ start, end }) => ({ start, end }));
|
||||
return {
|
||||
regions,
|
||||
previewConfig: { totalMs, minItemDurationMs, hasOverlap, allRegionSpans: regions },
|
||||
clips: spans.map((span) => ({
|
||||
span,
|
||||
displaySpan: getClipDisplaySpan(span, spans, pixelsToValue(1)),
|
||||
})),
|
||||
};
|
||||
}, [regions, pixelsToValue, totalMs, minItemDurationMs, hasOverlap]);
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRegionDisplaySpan,
|
||||
snapRegionSpan,
|
||||
getClipDisplaySpan,
|
||||
getEmbeddedCaptionSpan,
|
||||
getPlayheadDisplayTime,
|
||||
getTimeAtClipSeam,
|
||||
} from "./clipPresentation";
|
||||
|
||||
describe("clip presentation", () => {
|
||||
it.each([
|
||||
1, 10, 100,
|
||||
])("reserves a 24px seam without changing media spans at %s ms/px", (msPerPixel) => {
|
||||
const clips = [
|
||||
{ start: 0, end: 10000 },
|
||||
{ start: 10000, end: 20000 },
|
||||
];
|
||||
const left = getClipDisplaySpan(clips[0], clips, msPerPixel);
|
||||
const right = getClipDisplaySpan(clips[1], clips, msPerPixel);
|
||||
expect((right.start - left.end) / msPerPixel).toBe(24);
|
||||
expect(left.start).toBe(0);
|
||||
expect(right.end).toBe(20000);
|
||||
expect(clips[0].end).toBe(clips[1].start);
|
||||
});
|
||||
it("does not inset a single clip and leaves tiny clips visible", () => {
|
||||
const span = { start: 0, end: 10 };
|
||||
expect(getClipDisplaySpan(span, [span], 20)).toEqual(span);
|
||||
const tiny = getClipDisplaySpan(span, [span, { start: 10, end: 20 }], 20);
|
||||
expect(tiny.end - tiny.start).toBeGreaterThan(0);
|
||||
});
|
||||
it("clips caption previews to the owning filmstrip, excluding its gutter", () => {
|
||||
const clips = [
|
||||
{ span: { start: 0, end: 1000 }, displaySpan: { start: 0, end: 900 } },
|
||||
{ span: { start: 1000, end: 2000 }, displaySpan: { start: 1100, end: 2000 } },
|
||||
];
|
||||
expect(getEmbeddedCaptionSpan({ start: 800, end: 1000 }, clips)).toEqual({
|
||||
start: 720,
|
||||
end: 900,
|
||||
});
|
||||
expect(getEmbeddedCaptionSpan({ start: 1000, end: 1200 }, clips)).toEqual({
|
||||
start: 1100,
|
||||
end: 1280,
|
||||
});
|
||||
expect(getEmbeddedCaptionSpan({ start: 2100, end: 2200 }, clips)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const splitClips = [
|
||||
{ span: { start: 0, end: 1000 }, displaySpan: { start: 0, end: 900 } },
|
||||
{ span: { start: 1000, end: 2000 }, displaySpan: { start: 1100, end: 2000 } },
|
||||
];
|
||||
it("jumps the playhead across a split gutter at the cut", () => {
|
||||
expect(getPlayheadDisplayTime(500, splitClips)).toBe(450);
|
||||
expect(getPlayheadDisplayTime(999, splitClips)).toBeCloseTo(899.1);
|
||||
expect(getPlayheadDisplayTime(1000, splitClips)).toBe(1100);
|
||||
expect(getPlayheadDisplayTime(1001, splitClips)).toBeCloseTo(1100.9);
|
||||
expect(getPlayheadDisplayTime(2000, splitClips)).toBe(2000);
|
||||
});
|
||||
it("seeks to the cut from either side of a decorative gutter", () => {
|
||||
for (const time of [900, 950, 1000, 1050, 1100]) {
|
||||
expect(getTimeAtClipSeam(time, splitClips)).toBe(1000);
|
||||
}
|
||||
expect(getTimeAtClipSeam(700, splitClips)).toBeCloseTo(700 / 0.9);
|
||||
expect(getPlayheadDisplayTime(700, [])).toBe(700);
|
||||
});
|
||||
it("preserves authored empty timeline time", () => {
|
||||
const clips = [
|
||||
splitClips[0],
|
||||
{ span: { start: 1500, end: 2500 }, displaySpan: { start: 1600, end: 2500 } },
|
||||
];
|
||||
expect(getTimeAtClipSeam(1200, clips)).toBe(1200);
|
||||
expect(getPlayheadDisplayTime(1200, clips)).toBe(1200);
|
||||
});
|
||||
|
||||
it("round-trips all media time without a frozen playhead at either side of the cut", () => {
|
||||
for (let time = 0; time <= 2000; time += 7) {
|
||||
expect(getTimeAtClipSeam(getPlayheadDisplayTime(time, splitClips), splitClips)).toBeCloseTo(
|
||||
time,
|
||||
8,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("aligns effect endpoints with opposite sides of a zero-duration seam", () => {
|
||||
expect(getRegionDisplaySpan({ start: 500, end: 1000 }, splitClips)).toEqual({
|
||||
start: 450,
|
||||
end: 900,
|
||||
});
|
||||
expect(getRegionDisplaySpan({ start: 1000, end: 1500 }, splitClips)).toEqual({
|
||||
start: 1100,
|
||||
end: 1550,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
1, 10, 50,
|
||||
])("soft-snaps within eight screen pixels at %s ms/px and releases outside", (msPerPixel) => {
|
||||
const clips = [
|
||||
{ start: 0, end: 10000 },
|
||||
{ start: 10000, end: 20000 },
|
||||
];
|
||||
const presentation = clips.map((span) => ({
|
||||
span,
|
||||
displaySpan: getClipDisplaySpan(span, clips, msPerPixel),
|
||||
}));
|
||||
const edge = presentation[0].displaySpan.end;
|
||||
const near = getTimeAtClipSeam(edge - 5 * msPerPixel, presentation);
|
||||
const far = getTimeAtClipSeam(edge - 12 * msPerPixel, presentation);
|
||||
expect(
|
||||
snapRegionSpan({ start: 1000, end: near }, [10000], presentation, msPerPixel, "end"),
|
||||
).toEqual({ start: 1000, end: 10000 });
|
||||
expect(
|
||||
snapRegionSpan({ start: 1000, end: far }, [10000], presentation, msPerPixel, "end").end,
|
||||
).toBe(far);
|
||||
const dragged = snapRegionSpan(
|
||||
{ start: near - 2000, end: near },
|
||||
[10000],
|
||||
presentation,
|
||||
msPerPixel,
|
||||
);
|
||||
expect(dragged).toEqual({ start: 8000, end: 10000 });
|
||||
});
|
||||
it("snaps to other block edges and preserves drag duration", () => {
|
||||
expect(snapRegionSpan({ start: 1510, end: 1810 }, [1500], [], 2)).toEqual({
|
||||
start: 1500,
|
||||
end: 1800,
|
||||
});
|
||||
expect(snapRegionSpan({ start: 1550, end: 1850 }, [1500], [], 2)).toEqual({
|
||||
start: 1550,
|
||||
end: 1850,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
|
||||
export const CLIP_SEAM_GAP_PX = 24;
|
||||
|
||||
/** Visual gutters never become gaps in the media timeline. */
|
||||
export function getClipDisplaySpan(span: Span, allClips: Span[], msPerPixel: number): Span {
|
||||
if (!(msPerPixel > 0)) return span;
|
||||
const hasBefore = allClips.some((other) => other !== span && other.end <= span.start);
|
||||
const hasAfter = allClips.some((other) => other !== span && other.start >= span.end);
|
||||
// Keep a visible body even for clips smaller than the normal seam gutter.
|
||||
const inset = Math.min((CLIP_SEAM_GAP_PX / 2) * msPerPixel, (span.end - span.start) / 3);
|
||||
return { start: span.start + (hasBefore ? inset : 0), end: span.end - (hasAfter ? inset : 0) };
|
||||
}
|
||||
|
||||
export function getEmbeddedCaptionSpan(
|
||||
span: Span,
|
||||
clips: { span: Span; displaySpan: Span }[],
|
||||
): Span | null {
|
||||
const clip = clips.find((clip) => span.start < clip.span.end && span.end > clip.span.start);
|
||||
if (!clip) return null;
|
||||
const start = mapSpanTime(Math.max(span.start, clip.span.start), clip.span, clip.displaySpan);
|
||||
const end = mapSpanTime(Math.min(span.end, clip.span.end), clip.span, clip.displaySpan);
|
||||
return end > start ? { start, end } : null;
|
||||
}
|
||||
|
||||
export type ClipPresentation = { span: Span; displaySpan: Span };
|
||||
|
||||
function mapSpanTime(time: number, from: Span, to: Span): number {
|
||||
if (from.end <= from.start) return to.start;
|
||||
return to.start + ((time - from.start) / (from.end - from.start)) * (to.end - to.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intentionally skip across split-clip gaps, following iMovie-like logic:
|
||||
* gaps consume zero timeline time (e.g. 2.8s → 2.8s), with no simulated playback.
|
||||
*/
|
||||
export function getPlayheadDisplayTime(
|
||||
time: number,
|
||||
clips: ClipPresentation[],
|
||||
edge: "start" | "end" = "start",
|
||||
): number {
|
||||
if (edge === "end") {
|
||||
const ending = clips.find(({ span }) => time > span.start && time <= span.end);
|
||||
if (ending) return mapSpanTime(time, ending.span, ending.displaySpan);
|
||||
}
|
||||
const clip =
|
||||
clips.find(({ span }) => time >= span.start && time < span.end) ??
|
||||
clips.find(({ span }) => time === span.end);
|
||||
if (!clip) return time;
|
||||
return mapSpanTime(time, clip.span, clip.displaySpan);
|
||||
}
|
||||
|
||||
/** Scrubbing a shared gutter selects the cut, which has no media duration. */
|
||||
export function getTimeAtClipSeam(time: number, clips: ClipPresentation[]): number {
|
||||
for (const left of clips) {
|
||||
const right = clips.find(({ span }) => span.start === left.span.end);
|
||||
if (right && time >= left.displaySpan.end && time <= right.displaySpan.start) {
|
||||
return right.span.start;
|
||||
}
|
||||
}
|
||||
const clip = clips.find(
|
||||
({ displaySpan }) => time >= displaySpan.start && time <= displaySpan.end,
|
||||
);
|
||||
return clip ? mapSpanTime(time, clip.displaySpan, clip.span) : time;
|
||||
}
|
||||
|
||||
/** Both sides of a cut share a timestamp, but occupy different visual edges. */
|
||||
export function getRegionDisplaySpan(span: Span, clips: ClipPresentation[]): Span {
|
||||
return {
|
||||
start: getPlayheadDisplayTime(span.start, clips, "start"),
|
||||
end: getPlayheadDisplayTime(span.end, clips, "end"),
|
||||
};
|
||||
}
|
||||
|
||||
/** A small screen-space magnet; moving farther away releases it without changing duration. */
|
||||
export function snapRegionSpan(
|
||||
span: Span,
|
||||
targets: number[],
|
||||
clips: ClipPresentation[],
|
||||
msPerPixel: number,
|
||||
edge?: "start" | "end",
|
||||
): Span {
|
||||
if (!(msPerPixel > 0)) return span;
|
||||
let best = 8 * msPerPixel;
|
||||
let delta = 0;
|
||||
for (const side of edge ? [edge] : (["start", "end"] as const)) {
|
||||
const position = getPlayheadDisplayTime(span[side], clips, side);
|
||||
for (const target of targets) {
|
||||
const distance = Math.abs(position - getPlayheadDisplayTime(target, clips, side));
|
||||
if (distance <= best) {
|
||||
best = distance;
|
||||
delta = target - span[side];
|
||||
}
|
||||
}
|
||||
}
|
||||
return edge
|
||||
? { ...span, [edge]: span[edge] + delta }
|
||||
: { start: span.start + delta, end: span.end + delta };
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type { Span } from "dnd-timeline";
|
||||
import type { ShortcutBinding } from "@/lib/shortcuts";
|
||||
import type { ZoomMode } from "../../types";
|
||||
|
||||
export interface ClipSequenceSpan extends Span {
|
||||
/** Insertion index after removing the active clip from the ordered sequence. */
|
||||
sequenceIndex?: number;
|
||||
}
|
||||
|
||||
export interface TimelineRegionSpan {
|
||||
id: string;
|
||||
start: number;
|
||||
|
||||
@@ -16,15 +16,6 @@ const BASE_SPANS = [
|
||||
{ id: "aud-1", start: 100, end: 500, rowId: "row-audio-0" },
|
||||
];
|
||||
|
||||
const hasBaseOverlap = (span: { start: number; end: number }, excludeId?: string, rowId?: string) =>
|
||||
BASE_SPANS.some(
|
||||
(region) =>
|
||||
region.id !== excludeId &&
|
||||
(!rowId || region.rowId === rowId) &&
|
||||
span.start < region.end &&
|
||||
span.end > region.start,
|
||||
);
|
||||
|
||||
describe("timeline dnd engine", () => {
|
||||
it("clamps item span to timeline bounds and min duration", () => {
|
||||
expect(
|
||||
@@ -82,49 +73,14 @@ describe("timeline dnd engine", () => {
|
||||
expect(resizedLeft.start).toBe(1000);
|
||||
});
|
||||
|
||||
it("keeps drag unchanged when already inside valid neighbour gap", () => {
|
||||
const dragged = clampDraggedSpanToNeighbours({ start: 1400, end: 2400 }, "b", "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
minItemDurationMs: 100,
|
||||
totalMs: 5000,
|
||||
});
|
||||
expect(dragged).toEqual({ start: 1400, end: 2400 });
|
||||
});
|
||||
|
||||
it("clamps drag to previous or next neighbour bounds", () => {
|
||||
const toLeftBoundary = clampDraggedSpanToNeighbours(
|
||||
{ start: -500, end: 500 },
|
||||
"b",
|
||||
"row-clip",
|
||||
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
|
||||
);
|
||||
expect(toLeftBoundary).toEqual({ start: 1000, end: 2000 });
|
||||
|
||||
const toRightBoundary = clampDraggedSpanToNeighbours(
|
||||
{ start: 2200, end: 3200 },
|
||||
"b",
|
||||
"row-clip",
|
||||
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
|
||||
);
|
||||
expect(toRightBoundary).toEqual({ start: 2000, end: 3000 });
|
||||
});
|
||||
|
||||
it("places a clip after the next neighbour once most of the dragged clip crosses its start", () => {
|
||||
const dragged = clampDraggedSpanToNeighbours({ start: 2600, end: 3600 }, "b", "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
minItemDurationMs: 100,
|
||||
totalMs: 5000,
|
||||
});
|
||||
expect(dragged).toEqual({ start: 3600, end: 4600 });
|
||||
});
|
||||
|
||||
it("allows the final clip drag to extend the timeline", () => {
|
||||
const dragged = clampDraggedSpanToNeighbours({ start: 5200, end: 5800 }, "c", "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
minItemDurationMs: 100,
|
||||
totalMs: 5000,
|
||||
});
|
||||
expect(dragged).toEqual({ start: 5200, end: 5800 });
|
||||
it("inserts into a compact sequence even when legacy positions contain gaps", () => {
|
||||
expect(
|
||||
clampDraggedSpanToNeighbours({ start: 1400, end: 2400 }, "b", "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
minItemDurationMs: 100,
|
||||
totalMs: 5000,
|
||||
}),
|
||||
).toEqual({ start: 1000, end: 2000, sequenceIndex: 1 });
|
||||
});
|
||||
|
||||
it("falls back to generic clamping when active drag item is unknown", () => {
|
||||
@@ -139,22 +95,25 @@ describe("timeline dnd engine", () => {
|
||||
|
||||
it("resolves resize end with overlap fallback semantics", () => {
|
||||
const result = resolveResizeEnd(
|
||||
"a",
|
||||
{ start: 900, end: 2200 },
|
||||
"aud-1",
|
||||
{ start: 100, end: 2200 },
|
||||
{
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
allRegionSpans: BASE_SPANS,
|
||||
hasOverlap: (span, id) => id === "a" && span.end > 1500,
|
||||
allRegionSpans: [
|
||||
...BASE_SPANS,
|
||||
{ id: "aud-2", rowId: "row-audio-0", start: 1500, end: 2500 },
|
||||
],
|
||||
hasOverlap: (span) => span.end > 1500,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ start: 900, end: 1500 });
|
||||
expect(result).toEqual({ start: 100, end: 1500 });
|
||||
});
|
||||
|
||||
it("returns null when resize still overlaps after neighbour clamp", () => {
|
||||
const result = resolveResizeEnd(
|
||||
"a",
|
||||
{ start: 900, end: 2200 },
|
||||
"aud-1",
|
||||
{ start: 100, end: 2200 },
|
||||
{
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
@@ -165,50 +124,95 @@ describe("timeline dnd engine", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves drag end with row resolver while preserving duration", () => {
|
||||
const sequence = [
|
||||
{ id: "a", start: 0, end: 1000, rowId: "row-clip" },
|
||||
{ id: "b", start: 1000, end: 2000, rowId: "row-clip" },
|
||||
{ id: "c", start: 2000, end: 3000, rowId: "row-clip" },
|
||||
];
|
||||
const sequenceConfig = {
|
||||
allRegionSpans: sequence,
|
||||
totalMs: 3000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: () => true,
|
||||
};
|
||||
|
||||
it("inserts A after B without jumping over C", () => {
|
||||
const result = resolveDragEnd("a", { start: 1100, end: 2100 }, "row-clip", sequenceConfig);
|
||||
expect(result).toEqual({
|
||||
rowId: "row-clip",
|
||||
span: { start: 1000, end: 2000, sequenceIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts B before A at the start of the sequence", () => {
|
||||
const result = resolveDragEnd("b", { start: 0, end: 1000 }, "row-clip", sequenceConfig);
|
||||
expect(result).toEqual({
|
||||
rowId: "row-clip",
|
||||
span: { start: 0, end: 1000, sequenceIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the final clip in the sequence when dragged beyond its end", () => {
|
||||
const result = resolveDragEnd("c", { start: 5200, end: 5800 }, "row-clip", sequenceConfig);
|
||||
expect(result).toEqual({
|
||||
rowId: "row-clip",
|
||||
span: { start: 2000, end: 3000, sequenceIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("inserts a middle clip after the final clip without adding empty time", () => {
|
||||
const result = resolveDragEnd("b", { start: 4200, end: 5200 }, "row-clip", sequenceConfig);
|
||||
expect(result).toEqual({
|
||||
rowId: "row-clip",
|
||||
span: { start: 2000, end: 3000, sequenceIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the resolved target row for primary sequence insertion", () => {
|
||||
const result = resolveDragEnd(
|
||||
"b",
|
||||
{ start: 1200, end: 1800 },
|
||||
"row-clip",
|
||||
{
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: () => false,
|
||||
},
|
||||
(id, rowId) => (id === "b" ? rowId : rowId),
|
||||
"a",
|
||||
{ start: 1100, end: 2100 },
|
||||
"row-audio-0",
|
||||
sequenceConfig,
|
||||
() => "row-clip",
|
||||
);
|
||||
expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } });
|
||||
expect(result?.span.sequenceIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves final clip drags beyond the current timeline duration", () => {
|
||||
const result = resolveDragEnd("c", { start: 5200, end: 5800 }, "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: () => false,
|
||||
it("allows restoring a trimmed clip into its adjacent clip's old time", () => {
|
||||
expect(resolveResizeEnd("a", { start: 0, end: 1500 }, sequenceConfig)).toEqual({
|
||||
start: 0,
|
||||
end: 1500,
|
||||
});
|
||||
expect(result).toEqual({ rowId: "row-clip", span: { start: 5200, end: 5800 } });
|
||||
});
|
||||
|
||||
it("resolves a middle clip drag after the final clip by extending the timeline", () => {
|
||||
const result = resolveDragEnd("b", { start: 4200, end: 5200 }, "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: () => false,
|
||||
it("allows restoring the final clip beyond the current sequence end", () => {
|
||||
expect(resolveResizeEnd("c", { start: 2000, end: 4000 }, sequenceConfig)).toEqual({
|
||||
start: 2000,
|
||||
end: 4000,
|
||||
});
|
||||
expect(result).toEqual({ rowId: "row-clip", span: { start: 4200, end: 5200 } });
|
||||
});
|
||||
|
||||
it("resolves an overlapping clip drag as an after-neighbour reorder intent", () => {
|
||||
const result = resolveDragEnd("b", { start: 3500, end: 4500 }, "row-clip", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: hasBaseOverlap,
|
||||
it("allows restoring a trimmed first clip before time zero for source-aware repacking", () => {
|
||||
expect(resolveResizeEnd("a", { start: -500, end: 1000 }, sequenceConfig)).toEqual({
|
||||
start: -500,
|
||||
end: 1000,
|
||||
});
|
||||
expect(result).toEqual({ rowId: "row-clip", span: { start: 3600, end: 4600 } });
|
||||
});
|
||||
|
||||
it("preserves the stationary edge when a clip trim crosses its minimum duration", () => {
|
||||
expect(resolveResizeEnd("b", { start: 1990, end: 2000 }, sequenceConfig)).toEqual({
|
||||
start: 1900,
|
||||
end: 2000,
|
||||
});
|
||||
expect(resolveResizeEnd("b", { start: 1000, end: 1010 }, sequenceConfig)).toEqual({
|
||||
start: 1000,
|
||||
end: 1100,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite clip resize positions", () => {
|
||||
expect(resolveResizeEnd("a", { start: NaN, end: 1000 }, sequenceConfig)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps non-clip drags bounded by the current timeline duration", () => {
|
||||
@@ -222,7 +226,7 @@ describe("timeline dnd engine", () => {
|
||||
});
|
||||
|
||||
it("returns null when drag still overlaps after neighbour clamp", () => {
|
||||
const result = resolveDragEnd("b", { start: 1200, end: 1800 }, "row-clip", {
|
||||
const result = resolveDragEnd("aud-1", { start: 1200, end: 1800 }, "row-audio-0", {
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import { CLIP_ROW_ID } from "../core/constants";
|
||||
import type { TimelineRegionSpan } from "../core/timelineTypes";
|
||||
import type { ClipSequenceSpan, TimelineRegionSpan } from "../core/timelineTypes";
|
||||
|
||||
export interface DndEngineConfig {
|
||||
totalMs: number;
|
||||
@@ -110,103 +110,23 @@ export function clampResizedSpanToNeighbours(
|
||||
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
|
||||
}
|
||||
|
||||
function getClipDragTotalMs(
|
||||
activeItem: TimelineRegionSpan | undefined,
|
||||
rowId: string | undefined,
|
||||
span: Span,
|
||||
totalMs: number,
|
||||
) {
|
||||
if (activeItem?.rowId !== CLIP_ROW_ID || rowId !== CLIP_ROW_ID) {
|
||||
return totalMs;
|
||||
}
|
||||
|
||||
return Math.max(totalMs, Math.ceil(span.end));
|
||||
}
|
||||
|
||||
function spansOverlap(left: Span, right: Span) {
|
||||
return left.start < right.end && left.end > right.start;
|
||||
}
|
||||
|
||||
function placeSpanAfterSibling(
|
||||
/** A primary-track drag inserts into the sequence; it never searches for empty time. */
|
||||
function resolveClipSequenceDrag(
|
||||
activeItem: TimelineRegionSpan,
|
||||
siblings: TimelineRegionSpan[],
|
||||
siblingIndex: number,
|
||||
duration: number,
|
||||
): Span {
|
||||
let start = siblings[siblingIndex].end;
|
||||
|
||||
for (let index = siblingIndex + 1; index < siblings.length; index += 1) {
|
||||
const sibling = siblings[index];
|
||||
if (start + duration <= sibling.start) {
|
||||
break;
|
||||
}
|
||||
start = sibling.end;
|
||||
}
|
||||
|
||||
return { start, end: start + duration };
|
||||
}
|
||||
|
||||
function placeSpanBeforeSibling(
|
||||
siblings: TimelineRegionSpan[],
|
||||
siblingIndex: number,
|
||||
duration: number,
|
||||
): Span | null {
|
||||
let end = siblings[siblingIndex].start;
|
||||
|
||||
for (let index = siblingIndex - 1; index >= 0; index -= 1) {
|
||||
const sibling = siblings[index];
|
||||
if (end - duration >= sibling.end) {
|
||||
break;
|
||||
}
|
||||
end = sibling.start;
|
||||
}
|
||||
|
||||
const start = end - duration;
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function resolveClipDragInsertionSpan(params: {
|
||||
activeItem: TimelineRegionSpan;
|
||||
siblings: TimelineRegionSpan[];
|
||||
proposedStart: number;
|
||||
duration: number;
|
||||
}): Span | null {
|
||||
const { activeItem, siblings, proposedStart, duration } = params;
|
||||
const proposedSpan = { start: proposedStart, end: proposedStart + duration };
|
||||
const proposedCenter = proposedStart + duration / 2;
|
||||
const delta = proposedStart - activeItem.start;
|
||||
|
||||
if (delta > 0) {
|
||||
const nextIndex = siblings.findIndex(
|
||||
(sibling) =>
|
||||
sibling.start >= activeItem.end &&
|
||||
spansOverlap(proposedSpan, sibling) &&
|
||||
proposedCenter >= sibling.start,
|
||||
);
|
||||
if (nextIndex >= 0) {
|
||||
return placeSpanAfterSibling(siblings, nextIndex, duration);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (delta < 0) {
|
||||
for (let index = siblings.length - 1; index >= 0; index -= 1) {
|
||||
const sibling = siblings[index];
|
||||
if (
|
||||
sibling.end <= activeItem.start &&
|
||||
spansOverlap(proposedSpan, sibling) &&
|
||||
proposedCenter <= sibling.end
|
||||
) {
|
||||
return placeSpanBeforeSibling(siblings, index, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
proposedStart: number,
|
||||
): ClipSequenceSpan {
|
||||
const duration = activeItem.end - activeItem.start;
|
||||
const center = proposedStart + duration / 2;
|
||||
const movingRight = proposedStart > activeItem.start;
|
||||
const sequenceIndex = siblings.filter((sibling) => {
|
||||
const siblingCenter = (sibling.start + sibling.end) / 2;
|
||||
return movingRight ? siblingCenter <= center : siblingCenter < center;
|
||||
}).length;
|
||||
const start = siblings
|
||||
.slice(0, sequenceIndex)
|
||||
.reduce((sum, sibling) => sum + sibling.end - sibling.start, 0);
|
||||
return { start, end: start + duration, sequenceIndex };
|
||||
}
|
||||
|
||||
export function clampDraggedSpanToNeighbours(
|
||||
@@ -227,25 +147,12 @@ export function clampDraggedSpanToNeighbours(
|
||||
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
|
||||
);
|
||||
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
|
||||
const proposedSpan = { start: proposedStart, end: proposedStart + duration };
|
||||
|
||||
if (activeItem.rowId === CLIP_ROW_ID && rowId === CLIP_ROW_ID) {
|
||||
const insertionSpan = resolveClipDragInsertionSpan({
|
||||
activeItem,
|
||||
siblings,
|
||||
proposedStart,
|
||||
duration,
|
||||
});
|
||||
if (insertionSpan) {
|
||||
const insertionTotalMs = getClipDragTotalMs(activeItem, rowId, insertionSpan, totalMs);
|
||||
return clampSpanToBounds(insertionSpan, {
|
||||
totalMs: insertionTotalMs,
|
||||
minItemDurationMs,
|
||||
});
|
||||
}
|
||||
if (activeItem.rowId === CLIP_ROW_ID && (rowId ?? activeItem.rowId) === CLIP_ROW_ID) {
|
||||
return resolveClipSequenceDrag(activeItem, siblings, proposedStart);
|
||||
}
|
||||
|
||||
const effectiveTotalMs = getClipDragTotalMs(activeItem, rowId, proposedSpan, totalMs);
|
||||
const effectiveTotalMs = totalMs;
|
||||
|
||||
const previousSibling = [...siblings]
|
||||
.reverse()
|
||||
@@ -274,6 +181,24 @@ export function resolveResizeEnd(
|
||||
>,
|
||||
): Span | null {
|
||||
const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config;
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
if (activeItem?.rowId === CLIP_ROW_ID) {
|
||||
if (!Number.isFinite(updatedSpan.start) || !Number.isFinite(updatedSpan.end)) return null;
|
||||
// Source limits are enforced by the clip command. Negative left edges and
|
||||
// extension beyond the old sequence end reveal trimmed source before repacking.
|
||||
const minDuration = Math.max(1, minItemDurationMs);
|
||||
const resizedLeft =
|
||||
updatedSpan.start !== activeItem.start && updatedSpan.end === activeItem.end;
|
||||
return resizedLeft
|
||||
? {
|
||||
start: Math.min(updatedSpan.start, updatedSpan.end - minDuration),
|
||||
end: updatedSpan.end,
|
||||
}
|
||||
: {
|
||||
start: updatedSpan.start,
|
||||
end: Math.max(updatedSpan.end, updatedSpan.start + minDuration),
|
||||
};
|
||||
}
|
||||
let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs });
|
||||
const effectiveMinDuration =
|
||||
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
@@ -307,7 +232,7 @@ export function resolveDragEnd(
|
||||
"allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap"
|
||||
>,
|
||||
resolveTargetRowId?: (id: string, proposedRowId: string) => string,
|
||||
): { span: Span; rowId: string } | null {
|
||||
): { span: ClipSequenceSpan; rowId: string } | null {
|
||||
const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config;
|
||||
const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
|
||||
|
||||
@@ -316,7 +241,20 @@ export function resolveDragEnd(
|
||||
? activeItem.end - activeItem.start
|
||||
: updatedSpan.end - updatedSpan.start;
|
||||
const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration };
|
||||
const effectiveTotalMs = getClipDragTotalMs(activeItem, resolvedRowId, dragSpan, totalMs);
|
||||
if (activeItem?.rowId === CLIP_ROW_ID && resolvedRowId === CLIP_ROW_ID) {
|
||||
const proposedStart = Number.isFinite(updatedSpan.start)
|
||||
? updatedSpan.start
|
||||
: activeItem.start;
|
||||
return {
|
||||
span: resolveClipSequenceDrag(
|
||||
activeItem,
|
||||
getSiblingSpans(activeItemId, resolvedRowId, allRegionSpans),
|
||||
proposedStart,
|
||||
),
|
||||
rowId: resolvedRowId,
|
||||
};
|
||||
}
|
||||
const effectiveTotalMs = totalMs;
|
||||
|
||||
let clamped = clampSpanToBounds(dragSpan, { totalMs: effectiveTotalMs, minItemDurationMs });
|
||||
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
isAudioTrackRowId,
|
||||
} from "../core/rows";
|
||||
import { spansOverlap } from "../core/spans";
|
||||
import type { TimelineRenderItem } from "../core/timelineTypes";
|
||||
import type { ClipSequenceSpan, TimelineRenderItem } from "../core/timelineTypes";
|
||||
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel";
|
||||
|
||||
interface UseTimelineDndBindingsParams {
|
||||
@@ -29,7 +29,7 @@ interface UseTimelineDndBindingsParams {
|
||||
captionCues: CaptionCue[];
|
||||
onZoomSpanChange: (id: string, span: Span) => void;
|
||||
onTrimSpanChange?: (id: string, span: Span) => void;
|
||||
onClipSpanChange?: (id: string, span: Span) => void;
|
||||
onClipSpanChange?: (id: string, span: ClipSequenceSpan) => void;
|
||||
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
|
||||
@@ -158,8 +158,10 @@ export function useTimelineDndBindings({
|
||||
zoomRegions,
|
||||
clipRegions,
|
||||
audioRegions,
|
||||
annotationRegions,
|
||||
captionCues,
|
||||
}),
|
||||
[zoomRegions, clipRegions, audioRegions],
|
||||
[zoomRegions, clipRegions, audioRegions, annotationRegions, captionCues],
|
||||
);
|
||||
|
||||
const getResolvedDropRowId = useCallback(
|
||||
@@ -168,7 +170,7 @@ export function useTimelineDndBindings({
|
||||
);
|
||||
|
||||
const handleItemSpanChange = useCallback(
|
||||
(id: string, span: Span, rowId?: string) => {
|
||||
(id: string, span: ClipSequenceSpan, rowId?: string) => {
|
||||
const itemKind = resolveItemKind(id);
|
||||
if (itemKind === "zoom") {
|
||||
onZoomSpanChange(id, span);
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ZoomFocus,
|
||||
ZoomRegion,
|
||||
} from "../../types";
|
||||
import type { TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import type { ClipSequenceSpan, TimelineShortcutBindings } from "../core/timelineTypes";
|
||||
import type { TimelineEditorHandle } from "../TimelineEditor";
|
||||
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
|
||||
import { useTimelineCaptionActions } from "./actions/useTimelineCaptionActions";
|
||||
@@ -43,7 +43,7 @@ interface UseTimelineEditorRuntimeParams {
|
||||
onTrimSpanChange?: (id: string, span: Span) => void;
|
||||
clipRegions: ClipRegion[];
|
||||
onClipSplit?: (splitMs: number) => void;
|
||||
onClipSpanChange?: (id: string, span: Span) => void;
|
||||
onClipSpanChange?: (id: string, span: ClipSequenceSpan) => void;
|
||||
onClipDelete?: (id: string) => void;
|
||||
selectedClipId?: string | null;
|
||||
onSelectClip?: (id: string | null) => void;
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SHORTCUTS } from "@/lib/shortcuts";
|
||||
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
|
||||
|
||||
vi.mock("react", () => ({ useEffect: (effect: () => void) => effect() }));
|
||||
|
||||
class Element {
|
||||
isContentEditable = false;
|
||||
inOverlay = false;
|
||||
closest() {
|
||||
return this.inOverlay ? this : null;
|
||||
}
|
||||
}
|
||||
class Input extends Element {}
|
||||
class Textarea extends Element {}
|
||||
class Select extends Element {}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function setup(selectedClipId: string | null = "clip") {
|
||||
type Params = Parameters<typeof useTimelineKeyboardShortcuts>[0];
|
||||
function setup(overrides: Partial<Params> = {}) {
|
||||
vi.stubGlobal("HTMLElement", Element);
|
||||
vi.stubGlobal("HTMLInputElement", Input);
|
||||
vi.stubGlobal("HTMLTextAreaElement", Textarea);
|
||||
vi.stubGlobal("HTMLSelectElement", Select);
|
||||
const addEventListener = vi.fn();
|
||||
vi.stubGlobal("window", { addEventListener, removeEventListener: vi.fn() });
|
||||
const deleteSelectedClip = vi.fn();
|
||||
// React useEffect is mocked to test listener registration without mounting a component.
|
||||
useTimelineKeyboardShortcuts({
|
||||
const params: Params = {
|
||||
isMac: true,
|
||||
keyShortcuts: DEFAULT_SHORTCUTS,
|
||||
isTimelineFocusedRef: { current: false },
|
||||
selectedClipId,
|
||||
deleteSelectedClip,
|
||||
} as unknown as Parameters<typeof useTimelineKeyboardShortcuts>[0]);
|
||||
hasAnyZoomBlocks: true,
|
||||
activateSelectAllZooms: vi.fn(),
|
||||
annotationCount: 0,
|
||||
selectedKeyframeId: null,
|
||||
selectedZoomId: null,
|
||||
selectedClipId: null,
|
||||
selectAllBlocksActive: false,
|
||||
addKeyframe: vi.fn(),
|
||||
handleAddZoom: vi.fn(),
|
||||
handleSplitClip: vi.fn(),
|
||||
handleAddAnnotation: vi.fn(),
|
||||
deleteSelectedKeyframe: vi.fn(),
|
||||
deleteSelectedZoom: vi.fn(),
|
||||
deleteSelectedClip: vi.fn(),
|
||||
deleteSelectedAnnotation: vi.fn(),
|
||||
deleteSelectedAudio: vi.fn(),
|
||||
deleteSelectedCaption: vi.fn(),
|
||||
cycleAnnotationsAtCurrentTime: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: useEffect is mocked to capture the listener without a React render.
|
||||
useTimelineKeyboardShortcuts(params);
|
||||
const handler = addEventListener.mock.calls[0][1] as (event: KeyboardEvent) => void;
|
||||
const press = (options: Record<string, unknown> = {}) => {
|
||||
const event = {
|
||||
key: "Backspace",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target: new Element(),
|
||||
preventDefault: vi.fn(),
|
||||
...options,
|
||||
@@ -37,32 +63,75 @@ function setup(selectedClipId: string | null = "clip") {
|
||||
handler(event as unknown as KeyboardEvent);
|
||||
return event;
|
||||
};
|
||||
return { press, deleteSelectedClip };
|
||||
return { press, params };
|
||||
}
|
||||
const selections = [
|
||||
["selectedClipId", "deleteSelectedClip"],
|
||||
["selectedZoomId", "deleteSelectedZoom"],
|
||||
["selectedAnnotationId", "deleteSelectedAnnotation"],
|
||||
["selectedAudioId", "deleteSelectedAudio"],
|
||||
["selectedCaptionId", "deleteSelectedCaption"],
|
||||
["selectedKeyframeId", "deleteSelectedKeyframe"],
|
||||
] as const;
|
||||
|
||||
describe("selected clip Backspace", () => {
|
||||
it("deletes the selected clip without requiring timeline focus", () => {
|
||||
const { press, deleteSelectedClip } = setup();
|
||||
expect(press().preventDefault).toHaveBeenCalledOnce();
|
||||
expect(deleteSelectedClip).toHaveBeenCalledOnce();
|
||||
describe.each(selections)("delete %s", (selection, action) => {
|
||||
it.each([false, true])("supports both delete keys with timeline focus %s", (focused) => {
|
||||
for (const key of ["Backspace", "Delete"]) {
|
||||
const { press, params } = setup({
|
||||
[selection]: "block",
|
||||
isTimelineFocusedRef: { current: focused },
|
||||
});
|
||||
expect(press({ key }).preventDefault).toHaveBeenCalledOnce();
|
||||
expect(params[action]).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
it("honors the configured delete shortcut outside the timeline", () => {
|
||||
const { press, params } = setup({ [selection]: "block" });
|
||||
expect(press({ key: "d", metaKey: true }).preventDefault).toHaveBeenCalledOnce();
|
||||
expect(params[action]).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each([
|
||||
new Input(),
|
||||
new Textarea(),
|
||||
new Select(),
|
||||
Object.assign(new Element(), { isContentEditable: true }),
|
||||
])("does not delete a clip while editing text or a form control", (target) => {
|
||||
const { press, deleteSelectedClip } = setup();
|
||||
Object.assign(new Element(), { inOverlay: true }),
|
||||
])("does not delete behind an editable control or overlay", (target) => {
|
||||
const { press, params } = setup({ [selection]: "block" });
|
||||
expect(press({ target }).preventDefault).not.toHaveBeenCalled();
|
||||
expect(deleteSelectedClip).not.toHaveBeenCalled();
|
||||
expect(params[action]).not.toHaveBeenCalled();
|
||||
});
|
||||
it("ignores consumed events, modifier shortcuts and missing selection", () => {
|
||||
const { press, deleteSelectedClip } = setup();
|
||||
for (const option of ["defaultPrevented", "ctrlKey", "metaKey", "altKey"])
|
||||
press({ [option]: true });
|
||||
expect(deleteSelectedClip).not.toHaveBeenCalled();
|
||||
const unselected = setup(null);
|
||||
unselected.press();
|
||||
expect(unselected.deleteSelectedClip).not.toHaveBeenCalled();
|
||||
it("ignores consumed/composing events and unrelated modifier combinations", () => {
|
||||
const { press, params } = setup({
|
||||
[selection]: "block",
|
||||
isTimelineFocusedRef: { current: true },
|
||||
});
|
||||
for (const flag of [
|
||||
"defaultPrevented",
|
||||
"isComposing",
|
||||
"ctrlKey",
|
||||
"metaKey",
|
||||
"altKey",
|
||||
"shiftKey",
|
||||
]) {
|
||||
press({ [flag]: true });
|
||||
}
|
||||
expect(params[action]).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("does not consume deletion without a selection", () => {
|
||||
expect(setup().press().preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
it("select-all zooms takes priority over an individual clip selection", () => {
|
||||
const { press, params } = setup({ selectAllBlocksActive: true, selectedClipId: "clip" });
|
||||
press();
|
||||
expect(params.deleteSelectedZoom).toHaveBeenCalledOnce();
|
||||
expect(params.deleteSelectedClip).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps creation and select-all shortcuts scoped to the timeline", () => {
|
||||
const { press, params } = setup();
|
||||
press({ key: "z" });
|
||||
press({ key: "a", metaKey: true });
|
||||
expect(params.handleAddZoom).not.toHaveBeenCalled();
|
||||
expect(params.activateSelectAllZooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -58,52 +58,28 @@ export function useTimelineKeyboardShortcuts({
|
||||
}: UseTimelineKeyboardShortcutsParams) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.defaultPrevented || e.isComposing) return;
|
||||
const eventTarget = e.target;
|
||||
if (
|
||||
eventTarget instanceof HTMLInputElement ||
|
||||
eventTarget instanceof HTMLTextAreaElement ||
|
||||
eventTarget instanceof HTMLSelectElement ||
|
||||
(eventTarget instanceof HTMLElement && eventTarget.isContentEditable)
|
||||
(eventTarget instanceof HTMLElement &&
|
||||
(eventTarget.isContentEditable ||
|
||||
eventTarget.closest(
|
||||
'[data-recording-library], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]',
|
||||
)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedClipId && e.key === "Backspace" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
deleteSelectedClip();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTimelineFocusedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) {
|
||||
if (!hasAnyZoomBlocks) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
activateSelectAllZooms();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe();
|
||||
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom();
|
||||
if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip();
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
|
||||
if (e.key === "Tab" && annotationCount > 0) {
|
||||
if (cycleAnnotationsAtCurrentTime(e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Selection survives inspector focus; deletion follows the selected block.
|
||||
if (
|
||||
e.key === "Delete" ||
|
||||
e.key === "Backspace" ||
|
||||
((e.key === "Delete" || e.key === "Backspace") &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey) ||
|
||||
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
|
||||
) {
|
||||
const target = resolveDeleteSelectionTarget({
|
||||
@@ -131,6 +107,33 @@ export function useTimelineKeyboardShortcuts({
|
||||
} else if (target === "caption") {
|
||||
deleteSelectedCaption();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTimelineFocusedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) {
|
||||
if (!hasAnyZoomBlocks) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
activateSelectAllZooms();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe();
|
||||
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom();
|
||||
if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip();
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
|
||||
if (e.key === "Tab" && annotationCount > 0) {
|
||||
if (cycleAnnotationsAtCurrentTime(e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -103,21 +103,21 @@ export function useTimelineSelection({
|
||||
]);
|
||||
|
||||
const deleteSelectedClip = useCallback(() => {
|
||||
if (!selectedClipId || !onClipDelete || !onSelectClip) return;
|
||||
if (!selectedClipId || !onClipDelete) return;
|
||||
onClipDelete(selectedClipId);
|
||||
onSelectClip(null);
|
||||
onSelectClip?.(null);
|
||||
}, [selectedClipId, onClipDelete, onSelectClip]);
|
||||
|
||||
const deleteSelectedAnnotation = useCallback(() => {
|
||||
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
|
||||
if (!selectedAnnotationId || !onAnnotationDelete) return;
|
||||
onAnnotationDelete(selectedAnnotationId);
|
||||
onSelectAnnotation(null);
|
||||
onSelectAnnotation?.(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedAudio = useCallback(() => {
|
||||
if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
|
||||
if (!selectedAudioId || !onAudioDelete) return;
|
||||
onAudioDelete(selectedAudioId);
|
||||
onSelectAudio(null);
|
||||
onSelectAudio?.(null);
|
||||
}, [selectedAudioId, onAudioDelete, onSelectAudio]);
|
||||
|
||||
const deleteSelectedCaption = useCallback(() => {
|
||||
@@ -148,6 +148,7 @@ export function useTimelineSelection({
|
||||
const handleSelectZoom = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectAllBlocksActive(false);
|
||||
setSelectedKeyframeId(null);
|
||||
onSelectZoom(id);
|
||||
},
|
||||
[onSelectZoom],
|
||||
@@ -156,6 +157,7 @@ export function useTimelineSelection({
|
||||
const handleSelectClip = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectAllBlocksActive(false);
|
||||
setSelectedKeyframeId(null);
|
||||
onSelectClip?.(id);
|
||||
},
|
||||
[onSelectClip],
|
||||
@@ -164,6 +166,7 @@ export function useTimelineSelection({
|
||||
const handleSelectAnnotation = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectAllBlocksActive(false);
|
||||
setSelectedKeyframeId(null);
|
||||
onSelectAnnotation?.(id);
|
||||
},
|
||||
[onSelectAnnotation],
|
||||
@@ -172,6 +175,7 @@ export function useTimelineSelection({
|
||||
const handleSelectAudio = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectAllBlocksActive(false);
|
||||
setSelectedKeyframeId(null);
|
||||
onSelectAudio?.(id);
|
||||
},
|
||||
[onSelectAudio],
|
||||
@@ -180,6 +184,7 @@ export function useTimelineSelection({
|
||||
const handleSelectCaption = useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectAllBlocksActive(false);
|
||||
setSelectedKeyframeId(null);
|
||||
onSelectCaption?.(id);
|
||||
},
|
||||
[onSelectCaption],
|
||||
|
||||
@@ -110,6 +110,8 @@ export function buildTimelineItems(params: {
|
||||
}
|
||||
|
||||
export function buildAllRegionSpans(params: {
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
captionCues?: CaptionCue[];
|
||||
zoomRegions: ZoomRegion[];
|
||||
clipRegions: ClipRegion[];
|
||||
audioRegions: AudioRegion[];
|
||||
@@ -133,7 +135,23 @@ export function buildAllRegionSpans(params: {
|
||||
end: r.endMs,
|
||||
rowId: getAudioTrackRowId(r.trackIndex ?? 0),
|
||||
}));
|
||||
return [...zooms, ...clips, ...audios];
|
||||
return [
|
||||
...zooms,
|
||||
...clips,
|
||||
...audios,
|
||||
...(params.annotationRegions ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: getAnnotationTrackRowId(r.trackIndex ?? 0),
|
||||
})),
|
||||
...(params.captionCues ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: CAPTION_ROW_ID,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveDropRowId(
|
||||
|
||||
@@ -2,11 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTimelineContentMinHeightPx,
|
||||
getTimelineRowsMinHeightPx,
|
||||
getTimelineViewportStretchFactor,
|
||||
TIMELINE_CLIP_ROW_HEIGHT_PX,
|
||||
TIMELINE_AXIS_HEIGHT_PX,
|
||||
TIMELINE_ROW_MIN_HEIGHT_PX,
|
||||
TIMELINE_VISIBLE_ROW_COUNT,
|
||||
} from "./timelineLayout";
|
||||
|
||||
describe("timelineLayout", () => {
|
||||
@@ -32,18 +30,20 @@ describe("timelineLayout", () => {
|
||||
|
||||
it("floors fractional row counts", () => {
|
||||
expect(getTimelineRowsMinHeightPx(2.9)).toBe(
|
||||
TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
|
||||
TIMELINE_CLIP_ROW_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX + 4,
|
||||
);
|
||||
expect(getTimelineContentMinHeightPx(2.9)).toBe(
|
||||
TIMELINE_AXIS_HEIGHT_PX + TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4,
|
||||
TIMELINE_AXIS_HEIGHT_PX +
|
||||
TIMELINE_CLIP_ROW_HEIGHT_PX +
|
||||
2 * TIMELINE_ROW_MIN_HEIGHT_PX +
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("stretches content height to keep three compact rows visible", () => {
|
||||
expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(3);
|
||||
expect(getTimelineViewportStretchFactor(2)).toBe(1);
|
||||
expect(getTimelineViewportStretchFactor(3)).toBe(1);
|
||||
expect(getTimelineViewportStretchFactor(6)).toBe(2);
|
||||
expect(getTimelineViewportStretchFactor(0)).toBe(1);
|
||||
it("fits clip and full zoom, or clip and two compact tracks, in the minimum viewport", () => {
|
||||
const availableHeight = 180 - 24;
|
||||
expect(getTimelineContentMinHeightPx(2)).toBeLessThanOrEqual(availableHeight);
|
||||
expect(getTimelineContentMinHeightPx(3)).toBeLessThanOrEqual(availableHeight);
|
||||
expect(getTimelineContentMinHeightPx(4)).toBeGreaterThan(availableHeight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const TIMELINE_AXIS_HEIGHT_PX = 32;
|
||||
export const TIMELINE_AXIS_HEIGHT_PX = 20;
|
||||
export const TIMELINE_ROW_MIN_HEIGHT_PX = 32;
|
||||
export const TIMELINE_CLIP_ROW_HEIGHT_PX = TIMELINE_ROW_MIN_HEIGHT_PX * 2;
|
||||
export const TIMELINE_VISIBLE_ROW_COUNT = 3;
|
||||
|
||||
function normalizeRowCount(rowCount: number) {
|
||||
if (!Number.isFinite(rowCount)) {
|
||||
@@ -13,22 +12,14 @@ function normalizeRowCount(rowCount: number) {
|
||||
|
||||
export function getTimelineRowsMinHeightPx(rowCount: number) {
|
||||
const count = normalizeRowCount(rowCount);
|
||||
// The first lane is a double-height filmstrip; reserve its extra space.
|
||||
// Clip stays full height. Zoom shares its space only when another lane exists.
|
||||
return count
|
||||
? TIMELINE_CLIP_ROW_HEIGHT_PX + (count - 1) * TIMELINE_ROW_MIN_HEIGHT_PX + count * 2
|
||||
? TIMELINE_CLIP_ROW_HEIGHT_PX +
|
||||
(count === 2 ? 2 : count - 1) * TIMELINE_ROW_MIN_HEIGHT_PX +
|
||||
count * 2
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function getTimelineContentMinHeightPx(rowCount: number) {
|
||||
return TIMELINE_AXIS_HEIGHT_PX + getTimelineRowsMinHeightPx(rowCount);
|
||||
}
|
||||
|
||||
export function getTimelineViewportStretchFactor(rowCount: number) {
|
||||
const normalizedRowCount = normalizeRowCount(rowCount);
|
||||
|
||||
if (normalizedRowCount <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(1, normalizedRowCount / TIMELINE_VISIBLE_ROW_COUNT);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.locator('[data-variant="clip"]')).toHaveAttribute("data-end-ms", "6000", {
|
||||
timeout: 20000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clip keyboard deletion is wired through the editor and undo restores it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const clips = page.locator('[data-variant="clip"]');
|
||||
await clips.click({ position: { x: 100, y: 20 } });
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(clips).toHaveCount(0);
|
||||
await page.keyboard.press("Meta+z");
|
||||
await expect(clips).toHaveCount(1);
|
||||
await clips.click({ position: { x: 100, y: 20 } });
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(clips).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("zoom can be deleted after using its inspector", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
|
||||
const zoom = page.locator('[data-variant="zoom"]');
|
||||
await expect(zoom).toHaveCount(1);
|
||||
await zoom.click();
|
||||
await expect(page.getByRole("button", { name: "Delete Zoom", exact: true })).toHaveCount(1);
|
||||
await page.locator("aside").getByRole("row", { name: "Manual", exact: true }).click();
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(zoom).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("annotation deletion works from its inspector without deleting while typing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
|
||||
const annotation = page.locator('[data-variant="annotation"]');
|
||||
await expect(annotation).toHaveCount(1);
|
||||
await annotation.click();
|
||||
const text = page.locator("aside").getByRole("textbox").first();
|
||||
await text.fill("Keep this annotation");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(annotation).toHaveCount(1);
|
||||
const remove = page.getByRole("button", { name: "Delete Annotation", exact: true });
|
||||
await remove.focus();
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(annotation).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("audio deletion works after changing inspector focus", async ({ page }) => {
|
||||
await page.locator('[data-variant="clip"]').click({ position: { x: 100, y: 20 } });
|
||||
await page.evaluate(() => {
|
||||
window.electronAPI.openAudioFilePicker = async () => ({
|
||||
success: true,
|
||||
path: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Audio", exact: true }).click();
|
||||
const audio = page.locator('[data-variant="audio"]');
|
||||
await expect(audio).toHaveCount(1);
|
||||
await audio.click();
|
||||
const remove = page.getByRole("button", { name: "Delete Audio", exact: true });
|
||||
await remove.focus();
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(audio).toHaveCount(0);
|
||||
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("caption remains deletable after the playhead leaves its span", async ({ page }) => {
|
||||
await page.getByRole("radio", { name: "Captions", exact: true }).click();
|
||||
const showCaptions = page.getByRole("switch", { name: "Show", exact: true });
|
||||
await page
|
||||
.locator('[data-slot="switch"]')
|
||||
.filter({ has: showCaptions })
|
||||
.locator('[data-slot="switch-control"]')
|
||||
.click();
|
||||
await expect(showCaptions).toBeChecked();
|
||||
await page
|
||||
.locator("[data-caption-add-target]")
|
||||
.first()
|
||||
.click({ position: { x: 100, y: 8 } });
|
||||
const caption = page.locator('[data-variant="caption"]');
|
||||
await expect(caption).toHaveCount(1);
|
||||
const text = page.getByRole("textbox", { name: "Text", exact: true });
|
||||
await text.fill("Caption to delete");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(caption).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Skip Forward", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const label = await page.getByTestId("playhead-cap").getAttribute("aria-label");
|
||||
return Number(label?.match(/[\d.]+/)?.[0]) * 1000;
|
||||
})
|
||||
.toBeGreaterThan(Number(await caption.getAttribute("data-end-ms")));
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(caption).toHaveCount(0);
|
||||
await expect(page.locator('[data-variant="clip"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("inspector delete buttons remove clips and zooms", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
|
||||
const zoom = page.locator('[data-variant="zoom"]');
|
||||
await zoom.click();
|
||||
await expect(page.locator("aside").getByRole("grid", { name: "Zoom level" })).toBeVisible();
|
||||
await expect(page.locator("aside").getByText("Animation", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("radio", { name: "Extensions", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Account", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: "test-results/zoom-inspector-cleaned.png",
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("button", { name: "Delete Zoom", exact: true }).click();
|
||||
await expect(zoom).toHaveCount(0);
|
||||
const clip = page.locator('[data-variant="clip"]');
|
||||
await clip.click({ position: { x: 100, y: 20 } });
|
||||
await page.getByRole("button", { name: "Delete Clip", exact: true }).click();
|
||||
await expect(clip).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("tool navigation leaves the annotation inspector", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
|
||||
await page.locator('[data-variant="annotation"]').click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Delete Annotation", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Cursor", exact: true }).click();
|
||||
await expect(
|
||||
page.locator("aside").getByRole("heading", { name: "Cursor", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("slider", { name: "Cursor Size", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Delete Annotation", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(page.locator('[data-variant="annotation"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a dragged annotation can be selected across its whole block and deleted without deleting footage", async ({
|
||||
page,
|
||||
}) => {
|
||||
const clip = page.locator('[data-variant="clip"]');
|
||||
await clip.click({ position: { x: 100, y: 20 } });
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
|
||||
const annotation = page.locator('[data-variant="annotation"]');
|
||||
const box = (await annotation.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 140, box.y + box.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => Number(await annotation.getAttribute("data-start-ms")))
|
||||
.toBeGreaterThan(0);
|
||||
await page.getByRole("radio", { name: "Scene", exact: true }).click();
|
||||
await annotation.click({ position: { x: 40, y: 1 } });
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Delete Annotation", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(annotation).toHaveCount(0);
|
||||
await expect(clip).toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test("split clips share one grip and captions live inside the filmstrip", async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
page.setDefaultTimeout(10000);
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
const clips = page.locator('[data-variant="clip"]');
|
||||
await expect(clips).toHaveCount(1, { timeout: 20000 });
|
||||
await expect(clips.first()).toHaveAttribute("data-end-ms", "6000");
|
||||
const scroll = page.getByTestId("timeline-scroll");
|
||||
const clipRow = page.locator('[data-timeline-row="row-clip"]');
|
||||
const rowBox = (await clipRow.boundingBox())!;
|
||||
await page.mouse.click(rowBox.x + rowBox.width / 2, rowBox.y - 8);
|
||||
await expect
|
||||
.poll(() => page.getByTestId("playhead-cap").getAttribute("aria-label"))
|
||||
.toBe("Playhead 3.0s");
|
||||
await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
|
||||
await expect(clips).toHaveCount(2);
|
||||
const first = clips.nth(0),
|
||||
second = clips.nth(1);
|
||||
const left = (await first.locator(".timeline-block").boundingBox())!;
|
||||
const right = (await second.locator(".timeline-block").boundingBox())!;
|
||||
expect(right.x - left.x - left.width).toBeCloseTo(24, 0);
|
||||
const seamGrip = page.getByTestId("clip-seam-grip");
|
||||
await expect(seamGrip).toHaveCount(1);
|
||||
await expect(first.getByTitle("Resize right")).toBeHidden();
|
||||
await expect(second.getByTitle("Resize left")).toBeHidden();
|
||||
const grip = (await seamGrip.boundingBox())!;
|
||||
expect(grip.x + grip.width / 2).toBeCloseTo((left.x + left.width + right.x) / 2, 0);
|
||||
await scroll.focus();
|
||||
await expect(scroll).toHaveCSS("outline-style", "none");
|
||||
await expect(scroll).toHaveCSS("box-shadow", "none");
|
||||
// Seeking anywhere in the decorative split gutter lands on the cut and
|
||||
// draws the playhead at the next clip, never over its shared grip.
|
||||
await page.mouse.click(left.x + left.width + 3, rowBox.y - 8);
|
||||
await expect(page.getByTestId("playhead-cap")).toHaveAttribute("aria-label", "Playhead 3.0s");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.getByTestId("timeline-playhead").locator(":scope > div").boundingBox())!
|
||||
.x,
|
||||
)
|
||||
.toBeCloseTo(right.x, 0);
|
||||
await second.click({ position: { x: 2, y: 20 } });
|
||||
const savedStart = Number(await second.getAttribute("data-start-ms"));
|
||||
expect(Number(await first.getAttribute("data-start-ms"))).toBe(0);
|
||||
expect(Number(await first.getAttribute("data-end-ms"))).toBe(savedStart);
|
||||
expect(Number(await second.getAttribute("data-end-ms"))).toBe(6000);
|
||||
|
||||
// A real trim starts from the visible edge, with no gutter-size jump in timing.
|
||||
const before = (await second.boundingBox())!;
|
||||
await page.mouse.move(before.x + 2, before.y + 20);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(before.x + 32, before.y + 20, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => 6000 - Number(await second.getAttribute("data-end-ms")))
|
||||
.toBeCloseTo((30 / rowBox.width) * 6000, -1);
|
||||
|
||||
await expect(second).toHaveAttribute("data-start-ms", String(savedStart));
|
||||
await page.getByRole("radio", { name: "Captions", exact: true }).click();
|
||||
const showCaptions = page.getByRole("switch", { name: "Show", exact: true });
|
||||
await page
|
||||
.locator('[data-slot="switch"]')
|
||||
.filter({ has: showCaptions })
|
||||
.locator('[data-slot="switch-control"]')
|
||||
.click({ timeout: 10000 });
|
||||
await expect(showCaptions).toBeChecked();
|
||||
const strip = page.locator("[data-caption-add-target]").first();
|
||||
await strip.click({ position: { x: 100, y: 8 } });
|
||||
const caption = page.locator('[data-variant="caption"]');
|
||||
await expect(caption).toHaveCount(1);
|
||||
const text = page.getByRole("textbox", { name: "Text", exact: true });
|
||||
await text.fill("And this is what the caption looks like inside a clip");
|
||||
await text.press("Enter");
|
||||
await expect(caption).toContainText("And this is what");
|
||||
expect((await caption.textContent())!.length).toBeLessThan(35);
|
||||
const captionBox = (await caption.boundingBox())!;
|
||||
const clipBox = (await first.locator(".timeline-block").boundingBox())!;
|
||||
expect(captionBox.y).toBeGreaterThan(clipBox.y);
|
||||
expect(captionBox.y + captionBox.height).toBeLessThanOrEqual(clipBox.y + clipBox.height);
|
||||
expect((await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!.height).toBe(64);
|
||||
expect(await scroll.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1);
|
||||
const captionStart = Number(await caption.getAttribute("data-start-ms"));
|
||||
const captionDuration = Number(await caption.getAttribute("data-end-ms")) - captionStart;
|
||||
await page.mouse.move(
|
||||
captionBox.x + captionBox.width / 2,
|
||||
captionBox.y + captionBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
captionBox.x + captionBox.width / 2 + 24,
|
||||
captionBox.y + captionBox.height / 2,
|
||||
{ steps: 8 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => Number(await caption.getAttribute("data-start-ms")) - captionStart)
|
||||
.toBeCloseTo((24 / rowBox.width) * 6000, -1);
|
||||
expect(
|
||||
Number(await caption.getAttribute("data-end-ms")) -
|
||||
Number(await caption.getAttribute("data-start-ms")),
|
||||
).toBeCloseTo(captionDuration, 0);
|
||||
await page.screenshot({ path: "test-results/embedded-captions.png", animations: "disabled" });
|
||||
});
|
||||
|
||||
test("background swaps use the section fade without sliding or overlapping panels", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installDesktopBridge(page);
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.locator('[data-background-panel="image"]')).toBeVisible({ timeout: 20000 });
|
||||
for (const name of ["Color", "Gradient", "Video", "Image"]) {
|
||||
await page.getByRole("row", { name, exact: true }).click();
|
||||
const panel = page.locator("[data-background-panel]");
|
||||
await expect(panel).toHaveCount(1);
|
||||
await expect(panel).toHaveAttribute("data-background-panel", name.toLowerCase());
|
||||
await expect(panel).toHaveCSS("transform", "none");
|
||||
await expect(panel).toHaveCSS("animation-name", "editor-section-enter");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.locator('[data-variant="clip"]')).toHaveAttribute("data-end-ms", "6000", {
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(page.getByLabel("Loading preview")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("narrow zooms retain the multiplier without a zoom icon", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click();
|
||||
const zoom = page.locator('[data-variant="zoom"] .timeline-block');
|
||||
await zoom.evaluate((node: HTMLElement) => {
|
||||
node.style.width = "28px";
|
||||
});
|
||||
await expect(zoom.locator(".zoom-value")).toBeVisible();
|
||||
await expect(zoom.locator(".zoom-mode")).toBeHidden();
|
||||
await expect(zoom.locator(".zoom-icon")).toBeHidden();
|
||||
expect(await zoom.locator(".zoom-value").innerText()).toMatch(/\d.*×/);
|
||||
await zoom.evaluate((node: HTMLElement) => {
|
||||
node.style.width = "180px";
|
||||
});
|
||||
await expect(zoom.locator(".zoom-icon")).toBeVisible();
|
||||
await expect(zoom.locator(".zoom-mode")).toBeVisible();
|
||||
});
|
||||
|
||||
test("annotations can be dragged into the canvas background and survive undo", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
|
||||
const annotation = page.locator("[data-annotation-id]");
|
||||
await expect(annotation).toBeVisible();
|
||||
await annotation.click();
|
||||
const overlay = (await page.locator("[data-preview-overlay]").boundingBox())!;
|
||||
const box = (await annotation.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(overlay.x + box.width / 2 + 3, overlay.y + box.height / 2 + 3, {
|
||||
steps: 12,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await annotation.boundingBox())!.x - overlay.x).toBeLessThan(8);
|
||||
await expect.poll(async () => (await annotation.boundingBox())!.y - overlay.y).toBeLessThan(8);
|
||||
await page.screenshot({ path: "test-results/annotation-canvas.png" });
|
||||
await page.locator("[data-timeline-panel]").focus();
|
||||
await page.keyboard.press("Meta+z");
|
||||
await expect
|
||||
.poll(async () => (await annotation.boundingBox())!.x)
|
||||
.toBeGreaterThan(overlay.x + 8);
|
||||
});
|
||||
|
||||
test("cloud sharing is reachable from Export and the account control", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.getByRole("button", { name: "Export", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Create share link", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Sign into Recordly" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Sign in to publish this video and manage its shared link."),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: "test-results/cloud-sign-in.png", animations: "disabled" });
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.locator(".modal__body")).toBeVisible();
|
||||
expect(await dialog.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Recordly account", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Sign into Recordly" })).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test("Space toggles once per press, including on the focused playback button", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
|
||||
timeout: 20000,
|
||||
});
|
||||
const play = page.getByRole("button", { name: "Play", exact: true });
|
||||
const pause = page.getByRole("button", { name: "Pause", exact: true });
|
||||
await expect(play).toBeVisible();
|
||||
await expect(page.locator('[data-variant="clip"]')).toBeVisible();
|
||||
await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, {
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('video[aria-hidden="true"]')
|
||||
.evaluate((video: HTMLVideoElement) => video.readyState),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
await play.focus();
|
||||
await page.keyboard.down("Space");
|
||||
await expect(pause).toBeVisible();
|
||||
await page.keyboard.up("Space");
|
||||
await expect(pause).toBeVisible();
|
||||
await page.keyboard.down("Space");
|
||||
await expect(play).toBeVisible();
|
||||
// Repeated keydowns from holding the key must not toggle again.
|
||||
await page.keyboard.down("Space");
|
||||
await page.keyboard.down("Space");
|
||||
await expect(play).toBeVisible();
|
||||
await page.keyboard.up("Space");
|
||||
await expect(play).toBeVisible();
|
||||
// A fresh press still works immediately, without a debounce delay.
|
||||
await page.keyboard.press("Space");
|
||||
await expect(pause).toBeVisible();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(play).toBeVisible();
|
||||
await page.getByTestId("timeline-scroll").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(pause).toBeVisible();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(play).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test, type Page, type Locator } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
async function prepare(page: Page) {
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
const clips = page.locator('[data-variant="clip"]');
|
||||
await expect(clips.first()).toHaveAttribute("data-end-ms", "6000", { timeout: 20000 });
|
||||
await expect(clips.first().locator("img").first()).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByLabel("Loading preview")).toHaveCount(0);
|
||||
const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
|
||||
await page.mouse.click(row.x + row.width / 2, row.y - 8);
|
||||
await expect(page.getByTestId("playhead-cap")).toHaveAttribute("aria-label", "Playhead 3.0s");
|
||||
await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
|
||||
await expect(clips).toHaveCount(2);
|
||||
return { clips, row };
|
||||
}
|
||||
async function resizeEnd(page: Page, block: Locator, x: number) {
|
||||
const box = (await block.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x - 2, box.y + box.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("zoom ends snap to clip cuts, release, and can span the gutter", async ({ page }) => {
|
||||
const { clips } = await prepare(page);
|
||||
const first = (await clips.first().locator(".timeline-block").boundingBox())!;
|
||||
const second = (await clips.nth(1).locator(".timeline-block").boundingBox())!;
|
||||
const zoomRow = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
|
||||
await page.mouse.click(first.x + first.width / 3, zoomRow.y + 24);
|
||||
const zoom = page.locator('[data-variant="zoom"]');
|
||||
await expect(zoom).toHaveCount(1);
|
||||
await resizeEnd(page, zoom, first.x + first.width - 4);
|
||||
await expect(zoom).toHaveAttribute("data-end-ms", "3000");
|
||||
let box = (await zoom.boundingBox())!;
|
||||
expect(box.x + box.width).toBeCloseTo(first.x + first.width, 0);
|
||||
await resizeEnd(page, zoom, first.x + first.width - 30);
|
||||
await expect
|
||||
.poll(async () => Number(await zoom.getAttribute("data-end-ms")))
|
||||
.toBeLessThan(2900);
|
||||
await resizeEnd(page, zoom, second.x + second.width / 2);
|
||||
await expect
|
||||
.poll(async () => Number(await zoom.getAttribute("data-end-ms")))
|
||||
.toBeGreaterThan(4000);
|
||||
box = (await zoom.boundingBox())!;
|
||||
const gapX = (first.x + first.width + second.x) / 2;
|
||||
const hit = await page.evaluate(
|
||||
({ x, y }) => document.elementFromPoint(x, y)?.closest('[data-variant="zoom"]') !== null,
|
||||
{ x: gapX, y: box.y + box.height / 2 },
|
||||
);
|
||||
expect(hit).toBe(true);
|
||||
await expect(zoom).toHaveCSS("clip-path", "none");
|
||||
await page.screenshot({ path: "test-results/gap-snapping.png" });
|
||||
});
|
||||
|
||||
test("caption lands exactly where its mapped hover preview appears", async ({ page }) => {
|
||||
const { clips } = await prepare(page);
|
||||
await page.getByRole("radio", { name: "Captions", exact: true }).click();
|
||||
const show = page.getByRole("switch", { name: "Show", exact: true });
|
||||
await page
|
||||
.locator('[data-slot="switch"]')
|
||||
.filter({ has: show })
|
||||
.locator('[data-slot="switch-control"]')
|
||||
.click();
|
||||
const first = (await clips.first().locator(".timeline-block").boundingBox())!;
|
||||
const target = (await page.locator("[data-caption-add-target]").first().boundingBox())!;
|
||||
const x = first.x + first.width * 0.8,
|
||||
y = target.y + 8;
|
||||
await page.mouse.move(x, y);
|
||||
const preview = page.getByTestId("timeline-add-preview").locator(":scope > div");
|
||||
await expect(preview).toBeVisible();
|
||||
const expected = (await preview.boundingBox())!;
|
||||
await page.mouse.click(x, y);
|
||||
const caption = page.locator('[data-variant="caption"]');
|
||||
await expect(caption).toHaveCount(1);
|
||||
expect((await caption.boundingBox())!.x).toBeCloseTo(expected.x, 0);
|
||||
});
|
||||
|
||||
test("dragging a zoom soft-snaps its end to the cut without changing duration", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { clips } = await prepare(page);
|
||||
const first = (await clips.first().locator(".timeline-block").boundingBox())!;
|
||||
const row = (await page.locator('[data-timeline-row="row-zoom"]').boundingBox())!;
|
||||
await page.mouse.click(first.x + first.width / 3, row.y + 24);
|
||||
const zoom = page.locator('[data-variant="zoom"]');
|
||||
await expect(zoom).toHaveCount(1);
|
||||
const start = Number(await zoom.getAttribute("data-start-ms"));
|
||||
const end = Number(await zoom.getAttribute("data-end-ms"));
|
||||
const box = (await zoom.boundingBox())!;
|
||||
const delta = first.width * ((3000 - (end - start) - start) / 3000) - 4;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + delta, box.y + box.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect(zoom).toHaveAttribute("data-end-ms", "3000");
|
||||
await expect(zoom).toHaveAttribute("data-start-ms", String(3000 - (end - start)));
|
||||
const placed = (await zoom.boundingBox())!;
|
||||
expect(placed.x + placed.width).toBeCloseTo(first.x + first.width, 0);
|
||||
await page.keyboard.press("Meta+z");
|
||||
await expect(zoom).toHaveAttribute("data-start-ms", String(start));
|
||||
await expect(zoom).toHaveAttribute("data-end-ms", String(end));
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installDesktopBridge } from "./bridge";
|
||||
|
||||
test("zoom hover, adaptive lanes, clip edges, volume and playhead", async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
await installDesktopBridge(page, "filmstrip.mp4");
|
||||
await page.goto("/?windowType=editor");
|
||||
const clip = page.locator('[data-variant="clip"]');
|
||||
await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 });
|
||||
const scroll = page.getByTestId("timeline-scroll");
|
||||
const zoomRow = page.locator('[data-timeline-row="row-zoom"]');
|
||||
const clipRow = page.locator('[data-timeline-row="row-clip"]');
|
||||
for (const height of [800, 1000]) {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
expect(
|
||||
await scroll.evaluate((el) => el.scrollHeight - el.clientHeight),
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect((await zoomRow.boundingBox())!.height).toBe((await clipRow.boundingBox())!.height);
|
||||
await zoomRow.hover({ position: { x: 250, y: 25 } });
|
||||
await expect(page.getByTestId("timeline-add-preview")).toBeVisible();
|
||||
await zoomRow.click({ position: { x: 250, y: 25 } });
|
||||
const zoom = page.locator('[data-variant="zoom"]');
|
||||
await expect(zoom).toHaveCount(1);
|
||||
await expect(zoom.locator(".timeline-block")).toHaveCSS("background-image", "none");
|
||||
const normalHeight = (await zoomRow.boundingBox())!.height;
|
||||
await scroll.click({ position: { x: 600, y: 5 } });
|
||||
await page.keyboard.press("a");
|
||||
const annotation = page.locator('[data-variant="annotation"]');
|
||||
await expect(annotation).toHaveCount(1);
|
||||
expect((await zoomRow.boundingBox())!.height).toBe(normalHeight / 2);
|
||||
const annotationBox = (await annotation.boundingBox())!;
|
||||
const zoomBox = (await zoomRow.boundingBox())!;
|
||||
const scrollBox = (await scroll.boundingBox())!;
|
||||
expect(annotationBox.y).toBeGreaterThanOrEqual(zoomBox.y + zoomBox.height);
|
||||
expect(annotationBox.y + annotationBox.height).toBeLessThanOrEqual(
|
||||
scrollBox.y + scrollBox.height,
|
||||
);
|
||||
for (const height of [800, 1000]) {
|
||||
await page.setViewportSize({ width: 1440, height });
|
||||
expect(
|
||||
await scroll.evaluate((el) => el.scrollHeight - el.clientHeight),
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await expect(annotation.locator(".timeline-block")).toHaveCSS("background-image", "none");
|
||||
// Clip grips are decoration outside the true clip boundaries.
|
||||
const block = clip.locator(".timeline-block");
|
||||
const blockBox = (await block.boundingBox())!;
|
||||
expect((await clip.getByTitle("Resize left").boundingBox())!.x + 4).toBeLessThan(blockBox.x);
|
||||
expect((await clip.getByTitle("Resize right").boundingBox())!.x).toBeGreaterThan(
|
||||
blockBox.x + blockBox.width,
|
||||
);
|
||||
const before = (await clip.boundingBox())!;
|
||||
const beforeLabel = await clip.getAttribute("aria-label");
|
||||
await page.mouse.move(before.x + before.width - 2, before.y + before.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(before.x + before.width - 90, before.y + before.height / 2, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(clip).not.toHaveAttribute("aria-label", beforeLabel!);
|
||||
expect((await clip.boundingBox())!.x).toBeCloseTo(before.x, 0);
|
||||
await page.getByRole("button", { name: "Preview volume", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Preview volume" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const volume = dialog.getByRole("slider");
|
||||
await volume.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(volume).toHaveValue("0.99");
|
||||
expect((await dialog.boundingBox())!.y + (await dialog.boundingBox())!.height).toBeLessThan(
|
||||
(await page.getByRole("button", { name: "Preview volume", exact: true }).boundingBox())!.y,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
const cap = page.getByTestId("playhead-cap");
|
||||
await cap.hover();
|
||||
await expect(cap).toHaveCSS("width", "68px");
|
||||
await expect(cap.locator("span")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("timeline-playhead").locator(":scope > div")).toHaveCSS(
|
||||
"box-shadow",
|
||||
"none",
|
||||
);
|
||||
await expect(page.locator(".timeline-axis")).toHaveCount(0);
|
||||
await page.screenshot({ path: "test-results/timeline-redesign.png", animations: "disabled" });
|
||||
await page.mouse.move(600, 200);
|
||||
await expect(cap).toHaveCSS("width", "16px");
|
||||
// Removing the third lane restores full zoom height.
|
||||
await annotation.click();
|
||||
await page.getByRole("button", { name: "Delete Annotation", exact: true }).click();
|
||||
await expect(annotation).toHaveCount(0);
|
||||
expect((await zoomRow.boundingBox())!.height).toBe(normalHeight);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ test("filmstrips have persistent handles, conditional speed badges and centered
|
||||
await expect(zoom).toBeVisible();
|
||||
const clipHeight = (await clip.locator(".timeline-block").boundingBox())!.height;
|
||||
const zoomHeight = (await zoom.boundingBox())!.height;
|
||||
expect(Math.abs(clipHeight - zoomHeight * 2)).toBeLessThan(1);
|
||||
expect(Math.abs(clipHeight - zoomHeight)).toBeLessThan(1);
|
||||
const colors = await zoom.evaluate((node) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.background = "var(--accent)";
|
||||
|
||||
Reference in New Issue
Block a user