feat(timeline): refine clip editing and place yellow captions above footage

This commit is contained in:
webadderall
2026-09-20 18:52:26 +10:00
parent 3f9e71f1c6
commit 6f3f635b44
41 changed files with 2053 additions and 961 deletions
@@ -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,
+154 -17
View File
@@ -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;
}
+30 -6
View File
@@ -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);
}
+169
View File
@@ -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");
}
});
+71
View File
@@ -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([]);
});
+48
View File
@@ -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();
});
+104
View File
@@ -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));
});
+92
View File
@@ -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);
});
+1 -1
View File
@@ -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)";