Merge branch 'main' into feat/settings-panel-auto-props-extension-ui

This commit is contained in:
Alan Trebugeais
2026-05-09 12:00:30 +02:00
42 changed files with 3960 additions and 2648 deletions
+4
View File
@@ -1,5 +1,9 @@
export { default as PlaybackControls } from "./PlaybackControls";
export { SettingsPanel } from "./SettingsPanel";
export { default as TimelineEditor } from "./timeline/TimelineEditor";
export type {
TimelineEditorHandle,
TimelineEditorProps,
} from "./timeline/TimelineEditor";
export { default as VideoEditor } from "./VideoEditor";
export { default as VideoPlayback } from "./VideoPlayback";
@@ -20,6 +20,7 @@ interface ItemProps {
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
onSelectId?: (id: string) => void;
zoomDepth?: number;
zoomMode?: "auto" | "manual";
speedValue?: number;
@@ -52,6 +53,7 @@ export default function Item({
rowId,
isSelected = false,
onSelect,
onSelectId,
zoomDepth = 1,
zoomMode = "auto",
speedValue,
@@ -88,6 +90,10 @@ export default function Item({
);
const MIN_ITEM_PX = 6;
const handleSelect = () => {
onSelect?.();
onSelectId?.(id);
};
const safeItemStyle = {
...itemStyle,
minWidth: MIN_ITEM_PX,
@@ -101,8 +107,8 @@ export default function Item({
style={safeItemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
data-timeline-item="true"
onPointerDownCapture={handleSelect}
className="group h-full"
>
<div
@@ -128,7 +134,6 @@ export default function Item({
}}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div
@@ -1,17 +0,0 @@
import { cn } from "@/lib/utils";
interface SubrowProps {
children: React.ReactNode;
}
export default function Subrow({ children }: SubrowProps) {
return (
<div
className={cn(
"flex items-center min-h-[24px] gap-1 px-1.5 py-0 bg-transparent rounded-md text-foreground/60",
)}
>
{children}
</div>
);
}
@@ -1,28 +0,0 @@
.scrollArea {
scrollbar-width: thin;
scrollbar-color: hsl(var(--foreground) / 0.25) transparent;
}
.scrollArea::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: transparent;
}
.scrollArea::-webkit-scrollbar-corner {
background-color: transparent;
}
.scrollArea::-webkit-scrollbar-thumb {
background-color: hsl(var(--foreground) / 0.25);
border-radius: 10px;
border: 2px solid hsl(var(--editor-surface));
}
.scrollArea::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--foreground) / 0.35);
}
.scrollArea::-webkit-scrollbar-thumb:active {
background-color: hsl(var(--foreground) / 0.45);
}
File diff suppressed because it is too large Load Diff
@@ -1,389 +0,0 @@
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
Range,
ResizeEndEvent,
ResizeMoveEvent,
Span,
} from "dnd-timeline";
import { TimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
interface TimelineWrapperProps {
children: ReactNode;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
onRangeChange: Dispatch<SetStateAction<Range>>;
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span, rowId?: string) => void;
resolveTargetRowId?: (id: string, proposedRowId: string) => string;
allRegionSpans?: { id: string; start: number; end: number; rowId: string }[];
}
export default function TimelineWrapper({
children,
range,
videoDuration,
hasOverlap,
onRangeChange,
minItemDurationMs,
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
resolveTargetRowId,
allRegionSpans = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
const clampSpanToBounds = useCallback(
(span: Span): Span => {
const rawDuration = Math.max(span.end - span.start, 0);
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
if (totalMs === 0) {
const minDuration = Math.max(minItemDurationMs, 1);
const duration = Math.max(rawDuration, minDuration);
const start = Math.max(0, normalizedStart);
return {
start,
end: start + duration,
};
}
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
const end = start + duration;
return { start, end };
},
[minItemDurationMs, totalMs],
);
const clampRange = useCallback(
(candidate: Range): Range => {
if (totalMs === 0) {
const minSpan = Math.max(minVisibleRangeMs, 1);
const span = Math.max(candidate.end - candidate.start, minSpan);
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
return { start, end: start + span };
}
const rawStart = Math.max(0, candidate.start);
const rawEnd = candidate.end;
const clampedEnd = Math.min(rawEnd, totalMs);
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
const desiredSpan = clampedEnd - rawStart;
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
let finalStart = rawStart;
let finalEnd = finalStart + span;
if (finalEnd > totalMs) {
finalEnd = totalMs;
finalStart = Math.max(0, finalEnd - span);
}
return { start: finalStart, end: finalEnd };
},
[minVisibleRangeMs, totalMs],
);
const getSiblingSpans = useCallback(
(activeItemId: string, rowId?: string) => {
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
const resolvedRowId = rowId ?? activeItem?.rowId;
if (!resolvedRowId) {
return [];
}
return allRegionSpans
.filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId)
.sort((left, right) => left.start - right.start);
},
[allRegionSpans],
);
// When a resize overlaps neighbours, clamp the resized edge to the nearest boundary.
const clampResizedSpanToNeighbours = useCallback(
(span: Span, activeItemId: string): Span => {
const siblings = getSiblingSpans(activeItemId);
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
let { start, end } = span;
for (const r of siblings) {
// Span's right edge crossed into a region to the right
if (end > r.start && start < r.start) {
end = r.start;
}
// Span's left edge crossed into a region to the left
if (start < r.end && end > r.end) {
start = r.end;
}
}
// Ensure minimum duration after clamping
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
const resizedLeft = Boolean(
activeItem && span.start !== activeItem.start && span.end === activeItem.end,
);
if (resizedLeft) {
start = end - minDur;
} else {
end = start + minDur;
}
}
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
},
[allRegionSpans, getSiblingSpans, minItemDurationMs, totalMs],
);
// When a drag overlaps neighbours, keep duration fixed and stop at the nearest gap boundary.
const clampDraggedSpanToNeighbours = useCallback(
(span: Span, activeItemId: string, rowId?: string): Span => {
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
if (!activeItem) {
return clampSpanToBounds(span);
}
const siblings = getSiblingSpans(activeItemId, rowId);
const duration = Math.max(
activeItem.end - activeItem.start,
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
);
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
const previousSibling = [...siblings]
.reverse()
.find((region) => region.end <= activeItem.start);
const nextSibling = siblings.find((region) => region.start >= activeItem.end);
const minStart = previousSibling ? previousSibling.end : 0;
const maxStart = nextSibling
? nextSibling.start - duration
: totalMs > 0
? totalMs - duration
: proposedStart;
const start = Math.max(minStart, Math.min(proposedStart, maxStart));
return clampSpanToBounds({ start, end: start + duration });
},
[allRegionSpans, clampSpanToBounds, getSiblingSpans, minItemDurationMs, totalMs],
);
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
const effectiveMinDuration =
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
return;
}
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampSpanToBounds(
clampResizedSpanToNeighbours(clampedSpan, activeItemId),
);
// If still overlapping after clamping, fall back to original position
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[
clampResizedSpanToNeighbours,
clampSpanToBounds,
hasOverlap,
minItemDurationMs,
onItemSpanChange,
totalMs,
],
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const proposedRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !proposedRowId) return;
const activeItemId = event.active.id as string;
const resolvedRowId =
resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
// Drags are pure translations — always preserve the original duration.
// The span from getSpanFromDragEvent can drift due to pixel-to-ms
// rounding at different zoom levels, so pin to the known duration.
const activeItem = allRegionSpans.find((r) => r.id === activeItemId);
const originalDuration = activeItem
? activeItem.end - activeItem.start
: updatedSpan.end - updatedSpan.start;
const dragSpan: Span = {
start: updatedSpan.start,
end: updatedSpan.start + originalDuration,
};
let clampedSpan = clampSpanToBounds(dragSpan);
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) {
clampedSpan = clampDraggedSpanToNeighbours(
clampedSpan,
activeItemId,
resolvedRowId,
);
if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan, resolvedRowId);
},
[
allRegionSpans,
clampDraggedSpanToNeighbours,
clampSpanToBounds,
hasOverlap,
onItemSpanChange,
resolveTargetRowId,
],
);
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const formatTooltipMs = useCallback((ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
}, []);
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
const el = tooltipRef.current;
if (!el) return;
if (!span) {
el.style.opacity = "0";
return;
}
el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`;
el.style.opacity = "1";
if (screenX !== undefined) {
const parent = el.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
el.style.left = `${x}px`;
}
}
},
[formatTooltipMs],
);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
if (span) showTooltip(span);
},
[showTooltip],
);
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
);
const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
);
const handleRangeChange = useCallback(
(updater: (previous: Range) => Range) => {
onRangeChange((prev) => {
const normalized = totalMs > 0 ? clampRange(prev) : prev;
const desired = updater(normalized);
if (totalMs > 0) {
return clampRange(desired);
}
return desired;
});
},
[clampRange, onRangeChange, totalMs],
);
return (
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
resizeHandleWidth={28}
>
<div className="relative h-full min-h-0">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-editor-bg/90 text-[10px] text-foreground/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg"
style={{ opacity: 0, transition: "opacity 0.1s" }}
/>
</div>
</TimelineContext>
);
}
@@ -1,39 +0,0 @@
import type { RowDefinition } from "dnd-timeline";
import { useRow } from "dnd-timeline";
import type { CSSProperties, ReactNode } from "react";
interface TrackProps extends RowDefinition {
children: ReactNode;
hint?: string;
isEmpty?: boolean;
trackStyle?: CSSProperties;
}
export default function Track({ id, children, hint, isEmpty, trackStyle }: TrackProps) {
const { setNodeRef, rowWrapperStyle, rowStyle, rowSidebarStyle, setSidebarRef } = useRow({
id,
});
return (
<div
className="group/track flex-1 overflow-hidden bg-transparent"
style={{ ...rowWrapperStyle, marginBottom: 0, minHeight: 44, ...trackStyle }}
>
<div ref={setSidebarRef} style={rowSidebarStyle} />
<div
ref={setNodeRef}
className="relative flex-1 overflow-hidden"
style={{ ...rowStyle, minHeight: 44 }}
>
{isEmpty && hint ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center select-none">
<span className="rounded-full border border-foreground/[0.05] bg-foreground/[0.02] px-2 py-0.5 text-[9px] font-medium tracking-[0.04em] text-foreground/30 uppercase">
{hint}
</span>
</div>
) : null}
{children}
</div>
</div>
);
}
@@ -0,0 +1,104 @@
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),
[range.end, range.start],
);
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));
}
if (visibleStart <= maxTime) markerTimes.add(Math.round(visibleStart));
if (videoDurationMs > 0) markerTimes.add(Math.round(videoDurationMs));
const sorted = Array.from(markerTimes)
.filter((time) => time <= maxTime)
.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="h-8 bg-editor-bg border-b border-foreground/10 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: 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-[#2563EB]"
: "text-foreground/40",
)}
>
{marker.label}
</span>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
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);
@@ -0,0 +1,112 @@
import { useTimelineContext } from "dnd-timeline";
import { useEffect, useState, type RefObject } from "react";
import { cn } from "@/lib/utils";
import { formatPlayheadTime } from "../../core/time";
interface PlaybackCursorProps {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
timelineRef: RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}
export default function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
timelineRef,
keyframes = [],
}: PlaybackCursorProps) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current || !onSeek) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const snapThresholdMs = 150;
const nearbyKeyframe = keyframes.find(
(kf) =>
Math.abs(kf.time - absoluteMs) <= snapThresholdMs &&
kf.time >= range.start &&
kf.time <= range.end,
);
if (nearbyKeyframe) absoluteMs = nearbyKeyframe.time;
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = "";
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "ew-resize";
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
};
}, [
isDragging,
onSeek,
timelineRef,
sidebarWidth,
range.start,
range.end,
videoDurationMs,
pixelsToValue,
keyframes,
]);
if (videoDurationMs <= 0 || currentTimeMs < 0) return null;
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
if (clampedTime < range.start || clampedTime > range.end) return null;
const offset = valueToPixels(clampedTime - range.start);
return (
<div
className="absolute top-0 bottom-0 z-50 group/cursor"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
pointerEvents: "none",
}}
>
<div
className="absolute top-0 bottom-0 w-[2px] bg-[#2563EB] shadow-[0_0_10px_rgba(37,99,235,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(37,99,235,0.7)] transition-shadow"
style={{ [sideProperty]: `${offset}px` }}
onMouseDown={(e) => {
e.stopPropagation();
setIsDragging(true);
}}
>
<div
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
style={{ width: "16px", height: "16px" }}
>
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#2563EB] rotate-45 rounded-sm shadow-lg border border-foreground/20" />
</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",
isDragging ? "opacity-100" : "opacity-0",
)}
>
<span className="leading-5">{formatPlayheadTime(clampedTime)}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" title={addZoomLabel} aria-label={addZoomLabel}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button onClick={onSuggestZooms} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#2563EB] hover:bg-[#2563EB]/10 transition-all" title={suggestZoomsLabel} aria-label={suggestZoomsLabel}>
<WandSparkles className="w-4 h-4" />
</Button>
<Button onClick={onAddAnnotation} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all" title={addAnnotationLabel} aria-label={addAnnotationLabel}>
<MessageSquare className="w-4 h-4" />
</Button>
<Button onClick={onAddAudio} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-[#a855f7] hover:bg-[#a855f7]/10 transition-all" title={addAudioLabel} aria-label={addAudioLabel}>
<Music className="w-4 h-4" />
</Button>
<Button onClick={onSplitClip} variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all" title={splitClipLabel} aria-label={splitClipLabel}>
<Scissors className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1">
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-editor-surface-alt border-foreground/10">
{ASPECT_RATIOS.map((ratio) => (
<DropdownMenuItem 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]" />}
</DropdownMenuItem>
))}
<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 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" 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 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" aria-label="Custom aspect height" />
<Button variant="ghost" size="sm" onClick={onApplyCustomAspectRatio} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10">Set</Button>
{isCustomAspectRatio(aspectRatio) && <Check className="w-3 h-3 text-[#2563EB] ml-auto" />}
</div>
</DropdownMenuContent>
</DropdownMenu>
<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 text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all 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>
);
}
@@ -0,0 +1,677 @@
import { Plus } from "@phosphor-icons/react";
import { useTimelineContext } from "dnd-timeline";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type MouseEventHandler,
} from "react";
import { cn } from "@/lib/utils";
import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
getTimelineViewportStretchFactor,
TIMELINE_AXIS_HEIGHT_PX,
} from "../../timelineLayout";
import AudioWaveform from "../waveform/AudioWaveform";
import glassStyles from "../../ItemGlass.module.css";
import Item from "../../Item";
import Row from "../../Row";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
import type { AudioPeaksData, TimelineRenderItem } from "../../core/timelineTypes";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
getAudioTrackIndex,
getAudioTrackRowId,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../../core/rows";
import TimelineAxis from "../axis/TimelineAxis";
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
import PlaybackCursor from "../playhead/PlaybackCursor";
const HINT_CLIP = "Press C to split clip";
const HINT_ANNOTATION = "Press A to add annotation";
const HINT_AUDIO = "Click music icon to add audio";
interface TimelineCanvasProps {
items: TimelineRenderItem[];
videoDurationMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
canPlaceZoomAtMs?: (startMs: number) => boolean;
onSelectZoom?: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
onAddZoomAtMs?: (startMs: number) => void;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
selectAllBlocksActive?: boolean;
onClearBlockSelection?: () => void;
keyframes?: { id: string; time: number }[];
audioPeaks?: AudioPeaksData | null;
}
interface TimelineHoverParams {
direction: string;
sidebarWidth: number;
rangeStart: number;
rangeEnd: number;
videoDurationMs: number;
onAddZoomAtMs?: (startMs: number) => void;
canPlaceZoomAtMs?: (startMs: number) => boolean;
valueToPixels: (value: number) => number;
}
function useTimelineHover({
direction,
sidebarWidth,
rangeStart,
rangeEnd,
videoDurationMs,
onAddZoomAtMs,
canPlaceZoomAtMs,
valueToPixels,
}: TimelineHoverParams) {
const [isTimelineHovered, setIsTimelineHovered] = useState(false);
const [timelineHoverMs, setTimelineHoverMs] = useState<number | null>(null);
const [isZoomRowHovered, setIsZoomRowHovered] = useState(false);
const [zoomRowHoverMs, setZoomRowHoverMs] = useState<number | null>(null);
const visibleDurationMs = Math.max(1, rangeEnd - rangeStart);
const updateTimelineHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
const contentWidth = Math.max(1, rect.width - sidebarWidth);
const contentX =
direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth;
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)));
},
[direction, rangeStart, sidebarWidth, videoDurationMs, visibleDurationMs],
);
const handleTimelineMouseEnter = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateTimelineHoverTime],
);
const handleTimelineMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!isTimelineHovered) setIsTimelineHovered(true);
updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isTimelineHovered, updateTimelineHoverTime],
);
const handleTimelineMouseLeave = useCallback(() => {
setIsTimelineHovered(false);
setTimelineHoverMs(null);
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const updateZoomRowHoverTime = useCallback(
(clientX: number, rect: DOMRect) => {
if (rect.width <= 0) return;
const position =
direction === "rtl"
? Math.max(0, Math.min(rect.right - clientX, rect.width))
: Math.max(0, Math.min(clientX - rect.left, rect.width));
const ratio = position / rect.width;
const nextMs = rangeStart + ratio * visibleDurationMs;
setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs)));
},
[direction, rangeStart, videoDurationMs, visibleDurationMs],
);
const handleZoomRowMouseEnter = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[updateZoomRowHoverTime],
);
const handleZoomRowMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!isZoomRowHovered) setIsZoomRowHovered(true);
updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect());
},
[isZoomRowHovered, updateZoomRowHoverTime],
);
const handleZoomRowMouseLeave = useCallback(() => {
setIsZoomRowHovered(false);
setZoomRowHoverMs(null);
}, []);
const handleZoomRowClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (!onAddZoomAtMs || zoomRowHoverMs === null) return;
const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) return;
onAddZoomAtMs(startMs);
},
[canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs],
);
const ghostStartMs =
zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs));
const ghostDurationMs = Math.min(1000, videoDurationMs);
const ghostEndMs =
ghostStartMs === null
? null
: Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs));
const ghostStartOffsetPx =
ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart));
const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart));
const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx);
const timelineGhostOffsetPx =
timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart));
const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null;
const canShowGhostZoom =
isZoomRowHovered &&
ghostStartMs !== null &&
(onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false);
return {
canShowGhostPlayhead,
timelineGhostOffsetPx,
handleTimelineMouseEnter,
handleTimelineMouseMove,
handleTimelineMouseLeave,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
handleZoomRowMouseEnter,
handleZoomRowMouseMove,
handleZoomRowMouseLeave,
handleZoomRowClick,
};
}
interface TimelineCanvasRowsProps {
items: TimelineRenderItem[];
videoDurationMs: number;
selectAllBlocksActive: boolean;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
onSelectZoom?: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
audioPeaks?: AudioPeaksData | null;
direction: string;
canShowGhostZoom: boolean;
ghostStartMs: number | null;
ghostStartOffsetPx: number;
ghostWidthPx: number;
onZoomRowMouseEnter: MouseEventHandler<HTMLDivElement>;
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
}
const TimelineCanvasRows = memo(function TimelineCanvasRows({
items,
videoDurationMs,
selectAllBlocksActive,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
audioPeaks,
direction,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
onZoomRowMouseEnter,
onZoomRowMouseMove,
onZoomRowMouseLeave,
onZoomRowClick,
}: TimelineCanvasRowsProps) {
const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
const nextClipItems: TimelineRenderItem[] = [];
const nextZoomItems: TimelineRenderItem[] = [];
const annotationBuckets = new Map<number, TimelineRenderItem[]>();
const audioBuckets = new Map<number, TimelineRenderItem[]>();
for (const item of items) {
if (item.rowId === CLIP_ROW_ID) {
nextClipItems.push(item);
continue;
}
if (item.rowId === ZOOM_ROW_ID) {
nextZoomItems.push(item);
continue;
}
if (isAnnotationTrackRowId(item.rowId)) {
const trackIndex = getAnnotationTrackIndex(item.rowId);
const bucket = annotationBuckets.get(trackIndex);
if (bucket) bucket.push(item);
else annotationBuckets.set(trackIndex, [item]);
continue;
}
if (isAudioTrackRowId(item.rowId)) {
const trackIndex = getAudioTrackIndex(item.rowId);
const bucket = audioBuckets.get(trackIndex);
if (bucket) bucket.push(item);
else audioBuckets.set(trackIndex, [item]);
}
}
const annotationRowsSorted = Array.from(annotationBuckets.entries())
.sort(([left], [right]) => left - right)
.map(([trackIndex, rowItems]) => ({
rowId: getAnnotationTrackRowId(trackIndex),
items: rowItems,
}));
const audioRowsSorted = Array.from(audioBuckets.entries())
.sort(([left], [right]) => left - right)
.map(([trackIndex, rowItems]) => ({
rowId: getAudioTrackRowId(trackIndex),
items: rowItems,
}));
return {
clipItems: nextClipItems,
zoomItems: nextZoomItems,
annotationRows: annotationRowsSorted,
audioRows: audioRowsSorted,
};
}, [items]);
return (
<>
<Row id={CLIP_ROW_ID} isEmpty={clipItems.length === 0} hint={HINT_CLIP}>
{audioPeaks && <AudioWaveform peaks={audioPeaks} />}
<ClipMarkerOverlay videoDurationMs={videoDurationMs} />
{clipItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedClipId}
onSelectId={onSelectClip}
variant="clip"
>
{item.label}
</Item>
))}
</Row>
<Row
id={ZOOM_ROW_ID}
isEmpty={zoomItems.length === 0}
onMouseEnter={onZoomRowMouseEnter}
onMouseMove={onZoomRowMouseMove}
onMouseLeave={onZoomRowMouseLeave}
onClick={onZoomRowClick}
>
{canShowGhostZoom && ghostStartMs !== 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: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
: { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` }
}
>
<div
className={cn(
glassStyles.glassPurple,
"w-full h-full overflow-hidden flex items-center justify-center cursor-default relative opacity-80",
)}
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
<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>
)}
{zoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedZoomId}
onSelectId={onSelectZoom}
zoomDepth={item.zoomDepth}
zoomMode={item.zoomMode}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>
{annotationRows.map(({ rowId, items: rowItems }, index) => (
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_ANNOTATION : undefined}>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAnnotationId}
onSelectId={onSelectAnnotation}
variant="annotation"
>
{item.label}
</Item>
))}
</Row>
))}
{audioRows.map(({ rowId, items: rowItems }, index) => (
<Row key={rowId} id={rowId} isEmpty={rowItems.length === 0} hint={index === 0 ? HINT_AUDIO : undefined}>
{rowItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={selectAllBlocksActive || item.id === selectedAudioId}
onSelectId={onSelectAudio}
variant="audio"
>
{item.label}
</Item>
))}
</Row>
))}
</>
);
});
export default function TimelineCanvas({
items,
videoDurationMs,
currentTimeMs,
onSeek,
onAddZoomAtMs,
canPlaceZoomAtMs,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
selectAllBlocksActive = false,
onClearBlockSelection,
keyframes = [],
audioPeaks,
}: TimelineCanvasProps) {
const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } =
useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const [isSeeking, setIsSeeking] = useState(false);
const seekRafRef = useRef<number | null>(null);
const pendingSeekClientXRef = useRef<number | null>(null);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
setTimelineRef(node);
localTimelineRef.current = node;
},
[setTimelineRef],
);
const handleTimelineClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (isSeeking) return;
if (!onSeek || videoDurationMs <= 0) return;
if (onClearBlockSelection) {
onClearBlockSelection();
} else {
onSelectZoom?.(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
}
const rect = e.currentTarget.getBoundingClientRect();
const clickX =
direction === "rtl"
? rect.right - sidebarWidth - e.clientX
: e.clientX - rect.left - sidebarWidth;
if (clickX < 0) return;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
onSeek(absoluteMs / 1000);
},
[
isSeeking,
onSeek,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
onClearBlockSelection,
videoDurationMs,
sidebarWidth,
direction,
range.start,
pixelsToValue,
],
);
const getAbsoluteMsFromClientX = useCallback(
(clientX: number, rect: DOMRect) => {
const clickX =
direction === "rtl"
? rect.right - sidebarWidth - clientX
: clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
return Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
},
[direction, pixelsToValue, range.start, sidebarWidth, videoDurationMs],
);
const handleTimelineMouseDown = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return;
if ((e.target as HTMLElement).closest("[data-timeline-item]")) {
return;
}
if (onClearBlockSelection) {
onClearBlockSelection();
} else {
onSelectZoom?.(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
}
const rect = localTimelineRef.current.getBoundingClientRect();
onSeek(getAbsoluteMsFromClientX(e.clientX, rect) / 1000);
setIsSeeking(true);
e.preventDefault();
},
[
getAbsoluteMsFromClientX,
onClearBlockSelection,
onSeek,
onSelectAnnotation,
onSelectAudio,
onSelectClip,
onSelectZoom,
videoDurationMs,
],
);
useEffect(() => {
if (!isSeeking) return;
const flushSeek = () => {
seekRafRef.current = null;
if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) return;
const rect = localTimelineRef.current.getBoundingClientRect();
onSeek(getAbsoluteMsFromClientX(pendingSeekClientXRef.current, rect) / 1000);
};
const handleMouseMove = (event: globalThis.MouseEvent) => {
pendingSeekClientXRef.current = event.clientX;
if (seekRafRef.current === null) {
seekRafRef.current = requestAnimationFrame(flushSeek);
}
};
const handleMouseUp = () => {
if (seekRafRef.current !== null) {
cancelAnimationFrame(seekRafRef.current);
seekRafRef.current = null;
}
if (pendingSeekClientXRef.current !== null) {
flushSeek();
}
pendingSeekClientXRef.current = null;
setIsSeeking(false);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
if (seekRafRef.current !== null) {
cancelAnimationFrame(seekRafRef.current);
seekRafRef.current = null;
}
pendingSeekClientXRef.current = null;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [getAbsoluteMsFromClientX, isSeeking, onSeek]);
const timelineRowCount = useMemo(() => {
const annotationRowIds = new Set<string>();
const audioRowIds = new Set<string>();
for (const item of items) {
if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId);
if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId);
}
return 2 + annotationRowIds.size + audioRowIds.size;
}, [items]);
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
const sideProperty = direction === "rtl" ? "right" : "left";
const {
canShowGhostPlayhead,
timelineGhostOffsetPx,
handleTimelineMouseEnter,
handleTimelineMouseMove,
handleTimelineMouseLeave,
canShowGhostZoom,
ghostStartMs,
ghostStartOffsetPx,
ghostWidthPx,
handleZoomRowMouseEnter,
handleZoomRowMouseMove,
handleZoomRowMouseLeave,
handleZoomRowClick,
} = useTimelineHover({
direction,
sidebarWidth,
rangeStart: range.start,
rangeEnd: range.end,
videoDurationMs,
onAddZoomAtMs,
canPlaceZoomAtMs,
valueToPixels,
});
return (
<div
ref={setRefs}
style={{
...style,
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
}}
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
onMouseDown={handleTimelineMouseDown}
onClick={handleTimelineClick}
onMouseEnter={handleTimelineMouseEnter}
onMouseMove={handleTimelineMouseMove}
onMouseLeave={handleTimelineMouseLeave}
>
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
{canShowGhostPlayhead && (
<div
className="absolute top-0 bottom-0 z-[45] pointer-events-none"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth - 1}px`,
}}
>
<div className="absolute top-0 bottom-0 w-px bg-foreground/35" style={{ [sideProperty]: `${timelineGhostOffsetPx}px` }} />
</div>
)}
<div className="relative z-10 flex flex-1 min-h-0 flex-col" style={{ minHeight: timelineRowsMinHeightPx }}>
<TimelineCanvasRows
items={items}
videoDurationMs={videoDurationMs}
selectAllBlocksActive={selectAllBlocksActive}
selectedZoomId={selectedZoomId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedAudioId={selectedAudioId}
onSelectZoom={onSelectZoom}
onSelectClip={onSelectClip}
onSelectAnnotation={onSelectAnnotation}
onSelectAudio={onSelectAudio}
audioPeaks={audioPeaks}
direction={direction}
canShowGhostZoom={canShowGhostZoom}
ghostStartMs={ghostStartMs}
ghostStartOffsetPx={ghostStartOffsetPx}
ghostWidthPx={ghostWidthPx}
onZoomRowMouseEnter={handleZoomRowMouseEnter}
onZoomRowMouseMove={handleZoomRowMouseMove}
onZoomRowMouseLeave={handleZoomRowMouseLeave}
onZoomRowClick={handleZoomRowClick}
/>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { useTimelineContext } from "dnd-timeline";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AudioPeaksData } from "./useAudioPeaks";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { AudioPeaksData } from "../../core/timelineTypes";
interface AudioWaveformProps {
peaks: AudioPeaksData;
@@ -11,7 +11,7 @@ interface AudioWaveformProps {
* Automatically syncs with the timeline's visible range so the waveform
* scrolls and zooms together with the clip items above it.
*/
export default function AudioWaveform({ peaks }: AudioWaveformProps) {
function AudioWaveformComponent({ peaks }: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { range } = useTimelineContext();
const [resizeKey, setResizeKey] = useState(0);
@@ -87,3 +87,5 @@ export default function AudioWaveform({ peaks }: AudioWaveformProps) {
/>
);
}
export default memo(AudioWaveformComponent);
@@ -0,0 +1,221 @@
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
Range,
ResizeEndEvent,
ResizeMoveEvent,
Span,
} from "dnd-timeline";
import { TimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
import type { TimelineRegionSpan } from "../../core/timelineTypes";
import {
clampRange,
resolveDragEnd,
resolveResizeEnd,
} from "../../dnd/engine";
interface TimelineWrapperProps {
children: ReactNode;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
onRangeChange: Dispatch<SetStateAction<Range>>;
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span, rowId?: string) => void;
resolveTargetRowId?: (id: string, proposedRowId: string) => string;
allRegionSpans?: TimelineRegionSpan[];
}
export default function TimelineWrapper({
children,
range,
videoDuration,
hasOverlap,
onRangeChange,
minItemDurationMs,
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
resolveTargetRowId,
allRegionSpans = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
const activeItemId = event.active.id as string;
const resolvedSpan = resolveResizeEnd(activeItemId, updatedSpan, {
totalMs,
minItemDurationMs,
allRegionSpans,
hasOverlap,
});
if (!resolvedSpan) return;
onItemSpanChange(activeItemId, resolvedSpan);
},
[allRegionSpans, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs],
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const proposedRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !proposedRowId) return;
const activeItemId = event.active.id as string;
const resolved = resolveDragEnd(
activeItemId,
updatedSpan,
proposedRowId,
{
allRegionSpans,
totalMs,
minItemDurationMs,
hasOverlap,
},
resolveTargetRowId,
);
if (!resolved) return;
onItemSpanChange(activeItemId, resolved.span, resolved.rowId);
},
[
allRegionSpans,
hasOverlap,
minItemDurationMs,
onItemSpanChange,
resolveTargetRowId,
totalMs,
],
);
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const formatTooltipMs = useCallback((ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
}, []);
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
const el = tooltipRef.current;
if (!el) return;
if (!span) {
el.style.opacity = "0";
return;
}
el.textContent = `${formatTooltipMs(span.start)} – ${formatTooltipMs(span.end)}`;
el.style.opacity = "1";
if (screenX !== undefined) {
const parent = el.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
el.style.left = `${x}px`;
}
}
},
[formatTooltipMs],
);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
if (span) showTooltip(span);
},
[showTooltip],
);
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
);
const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
);
const handleRangeChange = useCallback(
(updater: (previous: Range) => Range) => {
onRangeChange((prev) => {
const normalized =
totalMs > 0 ? clampRange(prev, { totalMs, minVisibleRangeMs }) : prev;
const desired = updater(normalized);
if (totalMs > 0) {
return clampRange(desired, { totalMs, minVisibleRangeMs });
}
return desired;
});
},
[minVisibleRangeMs, onRangeChange, totalMs],
);
return (
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
resizeHandleWidth={28}
>
<div className="relative h-full min-h-0">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-editor-bg/90 text-[10px] text-foreground/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg"
style={{ opacity: 0, transition: "opacity 0.1s" }}
/>
</div>
</TimelineContext>
);
}
@@ -0,0 +1,9 @@
export const ZOOM_ROW_ID = "row-zoom";
export const CLIP_ROW_ID = "row-clip";
export const ANNOTATION_ROW_ID = "row-annotation";
export const AUDIO_ROW_ID = "row-audio";
export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`;
export const AUDIO_ROW_PREFIX = `${AUDIO_ROW_ID}-`;
export const FALLBACK_RANGE_MS = 1000;
export const TARGET_MARKER_COUNT = 12;
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
getAudioTrackIndex,
getAudioTrackRowId,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "./rows";
describe("timeline core/rows", () => {
it("builds and parses annotation rows", () => {
expect(getAnnotationTrackRowId(2.9)).toBe("row-annotation-2");
expect(getAnnotationTrackRowId(-5)).toBe("row-annotation-0");
expect(getAnnotationTrackIndex("row-annotation-4")).toBe(4);
expect(getAnnotationTrackIndex("row-annotation")).toBe(0);
expect(isAnnotationTrackRowId("row-annotation")).toBe(true);
expect(isAnnotationTrackRowId("row-annotation-1")).toBe(true);
});
it("handles invalid annotation row IDs safely", () => {
expect(getAnnotationTrackIndex("row-annotation-foo")).toBe(0);
expect(getAnnotationTrackIndex("other")).toBe(0);
expect(isAnnotationTrackRowId("row-audio-1")).toBe(false);
});
it("builds and parses audio rows", () => {
expect(getAudioTrackRowId(1.2)).toBe("row-audio-1");
expect(getAudioTrackRowId(-3)).toBe("row-audio-0");
expect(getAudioTrackIndex("row-audio-3")).toBe(3);
expect(getAudioTrackIndex("row-audio")).toBe(0);
expect(isAudioTrackRowId("row-audio")).toBe(true);
expect(isAudioTrackRowId("row-audio-1")).toBe(true);
});
it("handles invalid audio row IDs safely", () => {
expect(getAudioTrackIndex("row-audio-foo")).toBe(0);
expect(getAudioTrackIndex("other")).toBe(0);
expect(isAudioTrackRowId("row-annotation-1")).toBe(false);
});
});
@@ -0,0 +1,40 @@
import {
ANNOTATION_ROW_ID,
ANNOTATION_ROW_PREFIX,
AUDIO_ROW_ID,
AUDIO_ROW_PREFIX,
} from "./constants";
export function getAnnotationTrackRowId(trackIndex: number) {
return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`;
}
export function isAnnotationTrackRowId(rowId: string) {
return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX);
}
export function getAnnotationTrackIndex(rowId: string) {
if (rowId === ANNOTATION_ROW_ID) {
return 0;
}
const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
export function getAudioTrackRowId(trackIndex: number) {
return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`;
}
export function isAudioTrackRowId(rowId: string) {
return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX);
}
export function getAudioTrackIndex(rowId: string) {
if (rowId === AUDIO_ROW_ID) {
return 0;
}
const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { normalizeRegionSpan, spansOverlap } from "./spans";
describe("timeline core/spans", () => {
it("treats adjacent spans as non-overlapping", () => {
expect(spansOverlap({ start: 0, end: 100 }, { start: 100, end: 200 })).toBe(false);
});
it("detects strict overlap and containment", () => {
expect(spansOverlap({ start: 0, end: 101 }, { start: 100, end: 200 })).toBe(true);
expect(spansOverlap({ start: 50, end: 150 }, { start: 75, end: 100 })).toBe(true);
});
it("normalizes region to bounds and enforces min duration", () => {
expect(
normalizeRegionSpan({ startMs: -50, endMs: 10, totalMs: 1000, minDurationMs: 100 }),
).toEqual({ start: 0, end: 100 });
});
it("clamps start when requested end exceeds total", () => {
expect(
normalizeRegionSpan({ startMs: 980, endMs: 1200, totalMs: 1000, minDurationMs: 100 }),
).toEqual({ start: 900, end: 1000 });
});
it("keeps already valid spans unchanged", () => {
expect(
normalizeRegionSpan({ startMs: 100, endMs: 300, totalMs: 1000, minDurationMs: 50 }),
).toEqual({ start: 100, end: 300 });
});
it("never exceeds total when min duration is larger than total", () => {
expect(
normalizeRegionSpan({ startMs: 100, endMs: 300, totalMs: 80, minDurationMs: 100 }),
).toEqual({ start: 0, end: 80 });
});
});
@@ -0,0 +1,24 @@
import type { Span } from "dnd-timeline";
export function spansOverlap(left: Span, right: Span) {
return left.end > right.start && left.start < right.end;
}
export function normalizeRegionSpan(params: {
startMs: number;
endMs: number;
totalMs: number;
minDurationMs: number;
}) {
const { startMs, endMs, totalMs, minDurationMs } = params;
const safeTotalMs = Math.max(0, totalMs);
const safeMinDurationMs = Math.max(0, Math.min(minDurationMs, safeTotalMs));
const clampedStart = Math.max(0, Math.min(startMs, safeTotalMs));
const normalizedStart = Math.max(0, Math.min(clampedStart, safeTotalMs - safeMinDurationMs));
const normalizedEnd = Math.min(
safeTotalMs,
Math.max(endMs, normalizedStart + safeMinDurationMs),
);
return { start: normalizedStart, end: normalizedEnd };
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
calculateAxisScale,
calculateTimelineScale,
createInitialRange,
formatPlayheadTime,
formatTimeLabel,
normalizeWheelDeltaToPixels,
} from "./time";
describe("timeline core/time", () => {
it("creates fallback range for empty or invalid duration", () => {
expect(createInitialRange(0)).toEqual({ start: 0, end: 1000 });
expect(createInitialRange(-10)).toEqual({ start: 0, end: 1000 });
expect(createInitialRange(2500)).toEqual({ start: 0, end: 2500 });
});
it("computes scale defaults and caps", () => {
expect(calculateTimelineScale(0)).toEqual({
minItemDurationMs: 100,
defaultItemDurationMs: 1000,
minVisibleRangeMs: 300,
});
expect(calculateTimelineScale(1).defaultItemDurationMs).toBe(100);
expect(calculateTimelineScale(100).defaultItemDurationMs).toBe(5000);
expect(calculateTimelineScale(10_000).defaultItemDurationMs).toBe(30000);
});
it("formats timeline labels in fractional, whole-second, and hour modes", () => {
expect(formatTimeLabel(1234, 100)).toBe("0:01.23");
expect(formatTimeLabel(1234, 500)).toBe("0:01.2");
expect(formatTimeLabel(61_900, 1000)).toBe("1:01");
expect(formatTimeLabel(3_661_999, 1000)).toBe("1:01:01");
});
it("formats playhead labels for sub-minute and minute timelines", () => {
expect(formatPlayheadTime(1234)).toBe("1.2s");
expect(formatPlayheadTime(61_400)).toBe("1:01.4");
});
it("normalizes wheel delta by deltaMode", () => {
expect(normalizeWheelDeltaToPixels(2, 0)).toBe(2);
expect(normalizeWheelDeltaToPixels(2, 1)).toBe(32);
expect(normalizeWheelDeltaToPixels(2, 2)).toBe(480);
expect(normalizeWheelDeltaToPixels(-3, 1)).toBe(-48);
});
it("picks fine and coarse axis scales based on visible range", () => {
const tiny = calculateAxisScale(1);
const typical = calculateAxisScale(2000);
const huge = calculateAxisScale(24 * 60 * 60 * 1000);
expect(tiny.intervalMs).toBeGreaterThan(0);
expect(tiny.gridMs).toBeGreaterThan(0);
expect(typical.intervalMs).toBeGreaterThanOrEqual(tiny.intervalMs);
expect(huge.intervalMs).toBeGreaterThanOrEqual(typical.intervalMs);
});
});
@@ -0,0 +1,116 @@
import type { Range } from "dnd-timeline";
import { FALLBACK_RANGE_MS, TARGET_MARKER_COUNT } from "./constants";
export interface TimelineScaleConfig {
minItemDurationMs: number;
defaultItemDurationMs: number;
minVisibleRangeMs: number;
}
const SCALE_CANDIDATES = [
{ intervalSeconds: 0.05, gridSeconds: 0.01 },
{ intervalSeconds: 0.1, gridSeconds: 0.02 },
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
{ intervalSeconds: 1, gridSeconds: 0.25 },
{ intervalSeconds: 2, gridSeconds: 0.5 },
{ intervalSeconds: 5, gridSeconds: 1 },
{ intervalSeconds: 10, gridSeconds: 2 },
{ intervalSeconds: 15, gridSeconds: 3 },
{ intervalSeconds: 30, gridSeconds: 5 },
{ intervalSeconds: 60, gridSeconds: 10 },
{ intervalSeconds: 120, gridSeconds: 20 },
{ intervalSeconds: 300, gridSeconds: 30 },
{ intervalSeconds: 600, gridSeconds: 60 },
{ intervalSeconds: 900, gridSeconds: 120 },
{ intervalSeconds: 1800, gridSeconds: 180 },
{ intervalSeconds: 3600, gridSeconds: 300 },
];
export function calculateAxisScale(visibleRangeMs: number): {
intervalMs: number;
gridMs: number;
} {
const visibleSeconds = visibleRangeMs / 1000;
const candidate =
SCALE_CANDIDATES.find((scaleCandidate) => {
if (visibleSeconds <= 0) {
return true;
}
return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT;
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
return {
intervalMs: Math.round(candidate.intervalSeconds * 1000),
gridMs: Math.round(candidate.gridSeconds * 1000),
};
}
export function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
const minItemDurationMs = 100;
const defaultItemDurationMs =
totalMs > 0
? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000))
: Math.max(minItemDurationMs, 1000);
const minVisibleRangeMs = 300;
return {
minItemDurationMs,
defaultItemDurationMs,
minVisibleRangeMs,
};
}
export function createInitialRange(totalMs: number): Range {
if (totalMs > 0) {
return { start: 0, end: totalMs };
}
return { start: 0, end: FALLBACK_RANGE_MS };
}
export function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) {
if (deltaMode === 1) {
return delta * 16;
}
if (deltaMode === 2) {
return delta * 240;
}
return delta;
}
export function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
if (hours > 0) {
const minutesString = minutes.toString().padStart(2, "0");
const secondsString = Math.floor(seconds).toString().padStart(2, "0");
return `${hours}:${minutesString}:${secondsString}`;
}
if (fractionalDigits > 0) {
const secondsWithFraction = seconds.toFixed(fractionalDigits);
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
}
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
}
export function formatPlayheadTime(ms: number): string {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`;
return `${sec.toFixed(1)}s`;
}
@@ -0,0 +1,44 @@
import type { ShortcutBinding } from "@/lib/shortcuts";
import type { Span } from "dnd-timeline";
import type { ZoomMode } from "../../types";
export interface TimelineRegionSpan {
id: string;
start: number;
end: number;
rowId: string;
}
export interface TimelineRegion {
id: string;
startMs: number;
endMs: number;
}
export interface TimelineAudioRegion extends TimelineRegion {
trackIndex?: number;
}
export interface TimelineShortcutBindings {
addKeyframe: ShortcutBinding;
addZoom: ShortcutBinding;
splitClip: ShortcutBinding;
addAnnotation: ShortcutBinding;
deleteSelected: ShortcutBinding;
}
export interface TimelineRenderItem {
id: string;
rowId: string;
span: Span;
label: string;
zoomDepth?: number;
zoomMode?: ZoomMode;
speedValue?: number;
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
}
export interface AudioPeaksData {
durationMs: number;
peaks: Float32Array;
}
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
clampDraggedSpanToNeighbours,
clampRange,
clampResizedSpanToNeighbours,
clampSpanToBounds,
getSiblingSpans,
resolveDragEnd,
resolveResizeEnd,
} from "./engine";
const BASE_SPANS = [
{ id: "a", start: 0, end: 1000, rowId: "row-clip" },
{ id: "b", start: 1500, end: 2500, rowId: "row-clip" },
{ id: "c", start: 3000, end: 3600, rowId: "row-clip" },
{ id: "aud-1", start: 100, end: 500, rowId: "row-audio-0" },
];
describe("timeline dnd engine", () => {
it("clamps item span to timeline bounds and min duration", () => {
expect(clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 0, end: 120 });
expect(clampSpanToBounds({ start: 4900, end: 7000 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 2900, end: 5000 });
});
it("handles zero-duration timelines in span clamping", () => {
expect(clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 })).toEqual({ start: 0, end: 100 });
expect(clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 })).toEqual({ start: 50, end: 60 });
});
it("clamps visible range for bounded and unbounded timelines", () => {
expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 });
expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual({ start: 0, end: 300 });
});
it("resolves siblings by row and active item", () => {
expect(getSiblingSpans("b", undefined, BASE_SPANS).map((s) => s.id)).toEqual(["a", "c"]);
expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual(["a", "b", "c"]);
expect(getSiblingSpans("missing", undefined, BASE_SPANS)).toEqual([]);
});
it("clamps resize against nearest neighbours and min duration", () => {
const resizedRight = clampResizedSpanToNeighbours(
{ start: 900, end: 2000 },
"a",
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
);
expect(resizedRight.end).toBe(1500);
const resizedLeft = clampResizedSpanToNeighbours(
{ start: 900, end: 2500 },
"b",
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
);
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: 3500, end: 4500 },
"b",
"row-clip",
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
);
expect(toRightBoundary).toEqual({ start: 2000, end: 3000 });
});
it("falls back to generic clamping when active drag item is unknown", () => {
const dragged = clampDraggedSpanToNeighbours(
{ start: -10, end: 20 },
"missing",
"row-clip",
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
);
expect(dragged).toEqual({ start: 0, end: 100 });
});
it("resolves resize end with overlap fallback semantics", () => {
const result = resolveResizeEnd("a", { start: 900, end: 2200 }, {
totalMs: 5000,
minItemDurationMs: 100,
allRegionSpans: BASE_SPANS,
hasOverlap: (span, id) => id === "a" && span.end > 1500,
});
expect(result).toEqual({ start: 900, end: 1500 });
});
it("returns null when resize still overlaps after neighbour clamp", () => {
const result = resolveResizeEnd("a", { start: 900, end: 2200 }, {
totalMs: 5000,
minItemDurationMs: 100,
allRegionSpans: BASE_SPANS,
hasOverlap: () => true,
});
expect(result).toBeNull();
});
it("resolves drag end with row resolver while preserving duration", () => {
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),
);
expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } });
});
it("returns null when drag still overlaps after neighbour clamp", () => {
const result = resolveDragEnd(
"b",
{ start: 1200, end: 1800 },
"row-clip",
{
allRegionSpans: BASE_SPANS,
totalMs: 5000,
minItemDurationMs: 100,
hasOverlap: () => true,
},
);
expect(result).toBeNull();
});
it("keeps proposed row when no target row resolver is provided", () => {
const result = resolveDragEnd(
"aud-1",
{ start: 700, end: 1000 },
"row-audio-2",
{
allRegionSpans: BASE_SPANS,
totalMs: 5000,
minItemDurationMs: 100,
hasOverlap: () => false,
},
);
expect(result?.rowId).toBe("row-audio-2");
});
});
@@ -0,0 +1,171 @@
import type { Range, Span } from "dnd-timeline";
import type { TimelineRegionSpan } from "../core/timelineTypes";
export interface DndEngineConfig {
totalMs: number;
minItemDurationMs: number;
minVisibleRangeMs: number;
allRegionSpans: TimelineRegionSpan[];
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
}
export function clampSpanToBounds(span: Span, config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs">): Span {
const { totalMs, minItemDurationMs } = config;
const rawDuration = Math.max(span.end - span.start, 0);
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
if (totalMs === 0) {
const minDuration = Math.max(minItemDurationMs, 1);
const duration = Math.max(rawDuration, minDuration);
const start = Math.max(0, normalizedStart);
return { start, end: start + duration };
}
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
return { start, end: start + duration };
}
export function clampRange(candidate: Range, config: Pick<DndEngineConfig, "totalMs" | "minVisibleRangeMs">): Range {
const { totalMs, minVisibleRangeMs } = config;
if (totalMs === 0) {
const minSpan = Math.max(minVisibleRangeMs, 1);
const span = Math.max(candidate.end - candidate.start, minSpan);
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
return { start, end: start + span };
}
const rawStart = Math.max(0, candidate.start);
const rawEnd = candidate.end;
const clampedEnd = Math.min(rawEnd, totalMs);
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
const desiredSpan = clampedEnd - rawStart;
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
let finalStart = rawStart;
let finalEnd = finalStart + span;
if (finalEnd > totalMs) {
finalEnd = totalMs;
finalStart = Math.max(0, finalEnd - span);
}
return { start: finalStart, end: finalEnd };
}
export function getSiblingSpans(activeItemId: string, rowId: string | undefined, allRegionSpans: TimelineRegionSpan[]) {
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
const resolvedRowId = rowId ?? activeItem?.rowId;
if (!resolvedRowId) {
return [];
}
return allRegionSpans
.filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId)
.sort((left, right) => left.start - right.start);
}
export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): Span {
const { allRegionSpans, minItemDurationMs, totalMs } = config;
const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans);
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
let { start, end } = span;
for (const r of siblings) {
if (end > r.start && start < r.start) {
end = r.start;
}
if (start < r.end && end > r.end) {
start = r.end;
}
}
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
const resizedLeft = Boolean(activeItem && span.start !== activeItem.start && span.end === activeItem.end);
if (resizedLeft) {
start = end - minDur;
} else {
end = start + minDur;
}
}
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
}
export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, rowId: string | undefined, config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): Span {
const { allRegionSpans, minItemDurationMs, totalMs } = config;
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
if (!activeItem) {
return clampSpanToBounds(span, { totalMs, minItemDurationMs });
}
const siblings = getSiblingSpans(activeItemId, rowId, allRegionSpans);
const duration = Math.max(
activeItem.end - activeItem.start,
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
);
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
const previousSibling = [...siblings].reverse().find((region) => region.end <= activeItem.start);
const nextSibling = siblings.find((region) => region.start >= activeItem.end);
const minStart = previousSibling ? previousSibling.end : 0;
const maxStart = nextSibling ? nextSibling.start - duration : totalMs > 0 ? totalMs - duration : proposedStart;
const start = Math.max(minStart, Math.min(proposedStart, maxStart));
return clampSpanToBounds({ start, end: start + duration }, { totalMs, minItemDurationMs });
}
export function resolveResizeEnd(activeItemId: string, updatedSpan: Span, config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap">): Span | null {
const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config;
let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs });
const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clamped.end - clamped.start < effectiveMinDuration) {
return null;
}
if (hasOverlap(clamped, activeItemId)) {
clamped = clampSpanToBounds(
clampResizedSpanToNeighbours(clamped, activeItemId, {
allRegionSpans,
minItemDurationMs,
totalMs,
}),
{ totalMs, minItemDurationMs },
);
if (hasOverlap(clamped, activeItemId)) {
return null;
}
}
return clamped;
}
export function resolveDragEnd(
activeItemId: string,
updatedSpan: Span,
proposedRowId: string,
config: Pick<DndEngineConfig, "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap">,
resolveTargetRowId?: (id: string, proposedRowId: string) => string,
): { span: Span; rowId: string } | null {
const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config;
const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
const activeItem = allRegionSpans.find((r) => r.id === activeItemId);
const originalDuration = activeItem ? activeItem.end - activeItem.start : updatedSpan.end - updatedSpan.start;
const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration };
let clamped = clampSpanToBounds(dragSpan, { totalMs, minItemDurationMs });
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
clamped = clampDraggedSpanToNeighbours(clamped, activeItemId, resolvedRowId, {
allRegionSpans,
minItemDurationMs,
totalMs,
});
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
return null;
}
}
return { span: clamped, rowId: resolvedRowId };
}
@@ -0,0 +1,140 @@
import { useCallback, useMemo } from "react";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import type { TimelineAudioRegion } from "../../core/timelineTypes";
import { resolveAudioPlacement } from "../utils/timelineAudioPlacement";
import { timelineNotifications } from "../utils/timelineNotifications";
interface AudioFilePickerResult {
success: boolean;
path?: string;
}
interface TimelineAudioActionsDeps {
openFilePicker: () => Promise<AudioFilePickerResult | null | undefined>;
probeAudioDurationMs: (audioPath: string) => Promise<number>;
reportError: (title: string, description: string) => void;
}
interface UseTimelineAudioActionsParams {
timeline: {
videoDuration: number;
totalMs: number;
currentTimeMs: number;
};
regions: {
audio: TimelineAudioRegion[];
};
onAudioAdded?: (span: { start: number; end: number }, audioPath: string, trackIndex?: number) => void;
deps?: Partial<TimelineAudioActionsDeps>;
}
async function defaultOpenFilePicker(): Promise<AudioFilePickerResult | null | undefined> {
return window.electronAPI.openAudioFilePicker();
}
async function defaultProbeAudioDurationMs(audioPath: string): Promise<number> {
const resolved = await resolveMediaElementSource(audioPath);
return new Promise<number>((resolve) => {
const audio = new Audio();
const cleanup = () => {
audio.removeAttribute("src");
audio.load();
resolved.revoke();
};
audio.addEventListener(
"loadedmetadata",
() => {
resolve(Math.round(audio.duration * 1000));
cleanup();
},
{ once: true },
);
audio.addEventListener(
"error",
() => {
resolve(0);
cleanup();
},
{ once: true },
);
audio.src = resolved.src;
});
}
function buildTimelineAudioActionsDeps(
overrides?: Partial<TimelineAudioActionsDeps>,
): TimelineAudioActionsDeps {
return {
openFilePicker: overrides?.openFilePicker ?? defaultOpenFilePicker,
probeAudioDurationMs: overrides?.probeAudioDurationMs ?? defaultProbeAudioDurationMs,
reportError: overrides?.reportError ?? timelineNotifications.error,
};
}
export function useTimelineAudioActions({
timeline,
regions,
onAudioAdded,
deps: depsOverrides,
}: UseTimelineAudioActionsParams) {
const { videoDuration, totalMs, currentTimeMs } = timeline;
const { audio: audioRegions } = regions;
const deps = useMemo(() => buildTimelineAudioActionsDeps(depsOverrides), [depsOverrides]);
const handleAddAudio = useCallback(
async (preferredTrackIndex?: number) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
return;
}
const result = await deps.openFilePicker();
if (!result?.success || !result.path) {
return;
}
const audioPath = result.path;
const audioDurationMs = await deps.probeAudioDurationMs(audioPath);
if (audioDurationMs <= 0) {
deps.reportError(
"Could not read audio file",
"The selected file may be corrupted or in an unsupported format.",
);
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
if (totalMs - startPos <= 0) {
deps.reportError(
"Cannot place audio here",
"There is no remaining space at the current playhead position.",
);
return;
}
const placement = resolveAudioPlacement({
audioRegions,
startPos,
totalMs,
audioDurationMs,
preferredTrackIndex,
});
if (!placement) {
deps.reportError(
"Cannot place audio here",
"Audio region already exists at this location or not enough space available.",
);
return;
}
onAudioAdded(
{ start: startPos, end: startPos + placement.durationMs },
audioPath,
placement.trackIndex,
);
},
[videoDuration, totalMs, onAudioAdded, deps, currentTimeMs, audioRegions],
);
return { handleAddAudio };
}
@@ -0,0 +1,204 @@
import type { Span } from "dnd-timeline";
import { useCallback, useEffect, useMemo } from "react";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../../../types";
import { buildInteractionZoomSuggestions } from "../../zoomSuggestionUtils";
import { timelineNotifications } from "../utils/timelineNotifications";
interface UseTimelineZoomActionsParams {
timeline: {
videoDuration: number;
totalMs: number;
currentTimeMs: number;
};
regions: {
zoom: ZoomRegion[];
clip: { startMs: number; endMs: number }[];
};
cursorTelemetry: CursorTelemetryPoint[];
options: {
disableSuggestedZooms: boolean;
};
autoSuggestZoomsTrigger: number;
onAutoSuggestZoomsConsumed?: () => void;
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
}
export function useTimelineZoomActions({
timeline,
regions,
cursorTelemetry,
options,
autoSuggestZoomsTrigger,
onAutoSuggestZoomsConsumed,
onZoomAdded,
onZoomSuggested,
}: UseTimelineZoomActionsParams) {
const { videoDuration, totalMs, currentTimeMs } = timeline;
const { zoom: zoomRegions, clip: clipRegions } = regions;
const { disableSuggestedZooms } = options;
const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]);
const canPlaceZoomAtMs = useCallback(
(startMs: number) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return false;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return false;
}
const startPos = Math.max(0, Math.min(startMs, totalMs));
const activeClip =
clipRegions.length === 0
? { startMs: 0, endMs: totalMs }
: clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs);
if (!activeClip) {
return false;
}
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNextClipEdge = activeClip.endMs - startPos;
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
return !isOverlapping && availableDuration >= defaultDuration;
},
[videoDuration, totalMs, defaultRegionDurationMs, clipRegions, zoomRegions],
);
const addZoomAtMs = useCallback(
(startMs: number) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(startMs, totalMs));
if (!canPlaceZoomAtMs(startPos)) {
timelineNotifications.error(
"Cannot place zoom here",
"Zoom already exists here or there is not enough room before the next zoom or clip end.",
);
return;
}
onZoomAdded({ start: startPos, end: startPos + defaultDuration });
},
[videoDuration, totalMs, defaultRegionDurationMs, canPlaceZoomAtMs, onZoomAdded],
);
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
addZoomAtMs(currentTimeMs);
}, [videoDuration, totalMs, currentTimeMs, addZoomAtMs]);
const handleSuggestZooms = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
if (disableSuggestedZooms) {
timelineNotifications.info("Suggested zooms are unavailable while cursor looping is enabled.");
return;
}
if (!onZoomSuggested) {
timelineNotifications.error("Zoom suggestion handler unavailable");
return;
}
if (cursorTelemetry.length < 2) {
timelineNotifications.info(
"No cursor telemetry available",
"Record a screencast first to generate cursor-based suggestions.",
);
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const result = buildInteractionZoomSuggestions({
cursorTelemetry,
totalMs,
defaultDurationMs: defaultDuration,
reservedSpans: zoomRegions
.map((region) => ({ start: region.startMs, end: region.endMs }))
.sort((a, b) => a.start - b.start),
});
if (result.status === "no-telemetry") {
timelineNotifications.info(
"No usable cursor telemetry",
"The recording does not include enough cursor movement data.",
);
return;
}
if (result.status === "no-interactions") {
timelineNotifications.info(
"No clear interaction moments found",
"Try a recording with pauses or clicks around important actions.",
);
return;
}
if (result.status === "no-slots" || result.suggestions.length === 0) {
timelineNotifications.info(
"No auto-zoom slots available",
"Detected dwell points overlap existing zoom regions.",
);
return;
}
for (const region of result.suggestions) {
onZoomSuggested({ start: region.start, end: region.end }, region.focus);
}
timelineNotifications.success(
`Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`,
);
}, [
videoDuration,
totalMs,
disableSuggestedZooms,
onZoomSuggested,
cursorTelemetry,
defaultRegionDurationMs,
zoomRegions,
]);
useEffect(() => {
if (autoSuggestZoomsTrigger <= 0) {
return;
}
onAutoSuggestZoomsConsumed?.();
handleSuggestZooms();
}, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]);
return {
defaultRegionDurationMs,
canPlaceZoomAtMs,
addZoomAtMs,
handleAddZoom,
handleSuggestZooms,
};
}
@@ -1,22 +1,16 @@
import { useEffect, useRef, useState } from "react";
import type { AudioPeaksData } from "../core/timelineTypes";
/** Number of peak bins to produce — enough for smooth display at any zoom. */
const TARGET_PEAK_COUNT = 2048;
export interface AudioPeaksData {
/** One normalised amplitude value (0–1) per bin, covering the full duration. */
peaks: Float32Array;
/** Total duration of the decoded audio in milliseconds. */
durationMs: number;
}
/**
* Decode audio from a media file URL and produce a fixed-length array of peak
* amplitudes suitable for waveform visualisation.
*
* Returns `null` while loading or if the file has no decodeable audio.
*/
export function useAudioPeaks(fileUrl: string | null | undefined): AudioPeaksData | null {
export function useTimelineAudioPeaks(fileUrl: string | null | undefined): AudioPeaksData | null {
const [data, setData] = useState<AudioPeaksData | null>(null);
const urlRef = useRef(fileUrl);
@@ -0,0 +1,178 @@
import type { Span } from "dnd-timeline";
import { useCallback, useMemo } from "react";
import type {
AnnotationRegion,
AudioRegion,
ClipRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "../../types";
import type { TimelineRenderItem } from "../core/timelineTypes";
import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows";
import { spansOverlap } from "../core/spans";
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel";
interface UseTimelineDndBindingsParams {
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
clipRegions: ClipRegion[];
annotationRegions: AnnotationRegion[];
speedRegions: SpeedRegion[];
audioRegions: AudioRegion[];
onZoomSpanChange: (id: string, span: Span) => void;
onTrimSpanChange?: (id: string, span: Span) => void;
onClipSpanChange?: (id: string, span: Span) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
}
type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
export function useTimelineDndBindings({
zoomRegions,
trimRegions,
clipRegions,
annotationRegions,
speedRegions,
audioRegions,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
}: UseTimelineDndBindingsParams) {
const resolveItemKind = useCallback(
(id: string): TimelineItemKind => {
if (zoomRegions.some((r) => r.id === id)) return "zoom";
if (trimRegions.some((r) => r.id === id)) return "trim";
if (clipRegions.some((r) => r.id === id)) return "clip";
if (annotationRegions.some((r) => r.id === id)) return "annotation";
if (speedRegions.some((r) => r.id === id)) return "speed";
if (audioRegions.some((r) => r.id === id)) return "audio";
return null;
},
[zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
);
const resolveTrackIndex = useCallback(
(kind: "annotation" | "audio", id: string, rowId?: string): number => {
if (kind === "annotation") {
return rowId && isAnnotationTrackRowId(rowId)
? getAnnotationTrackIndex(rowId)
: (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0);
}
return rowId && isAudioTrackRowId(rowId)
? getAudioTrackIndex(rowId)
: (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0);
},
[annotationRegions, audioRegions],
);
const hasOverlap = useCallback(
(newSpan: Span, excludeId?: string, rowId?: string): boolean => {
if (!excludeId) return false;
const itemKind = resolveItemKind(excludeId);
if (itemKind === "annotation") return false;
const checkOverlap = (
regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
) =>
regions.some((region) => {
if (region.id === excludeId) return false;
return spansOverlap(newSpan, { start: region.startMs, end: region.endMs });
});
if (itemKind === "zoom") return checkOverlap(zoomRegions);
if (itemKind === "trim") return checkOverlap(trimRegions);
if (itemKind === "clip") return checkOverlap(clipRegions);
if (itemKind === "speed") return checkOverlap(speedRegions);
if (itemKind === "audio") {
const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId);
return checkOverlap(
audioRegions.filter((region) => (region.trackIndex ?? 0) === activeTrackIndex),
);
}
return false;
},
[
resolveItemKind,
resolveTrackIndex,
zoomRegions,
trimRegions,
clipRegions,
audioRegions,
speedRegions,
],
);
const timelineItems = useMemo<TimelineRenderItem[]>(
() =>
buildTimelineItems({
zoomRegions,
clipRegions,
annotationRegions,
audioRegions,
}),
[zoomRegions, clipRegions, annotationRegions, audioRegions],
);
const allRegionSpans = useMemo(
() =>
buildAllRegionSpans({
zoomRegions,
clipRegions,
audioRegions,
}),
[zoomRegions, clipRegions, audioRegions],
);
const getResolvedDropRowId = useCallback(
(id: string, proposedRowId: string) => resolveDropRowId(id, proposedRowId, timelineItems),
[timelineItems],
);
const handleItemSpanChange = useCallback(
(id: string, span: Span, rowId?: string) => {
const itemKind = resolveItemKind(id);
if (itemKind === "zoom") {
onZoomSpanChange(id, span);
} else if (itemKind === "trim") {
onTrimSpanChange?.(id, span);
} else if (itemKind === "clip") {
onClipSpanChange?.(id, span);
} else if (itemKind === "annotation") {
const nextTrackIndex = resolveTrackIndex("annotation", id, rowId);
onAnnotationSpanChange?.(id, span, nextTrackIndex);
} else if (itemKind === "speed") {
onSpeedSpanChange?.(id, span);
} else if (itemKind === "audio") {
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
onAudioSpanChange?.(id, span, nextTrackIndex);
}
},
[
resolveItemKind,
resolveTrackIndex,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
],
);
return {
hasOverlap,
timelineItems,
allRegionSpans,
getResolvedDropRowId,
handleItemSpanChange,
};
}
@@ -0,0 +1,290 @@
import type { Span } from "dnd-timeline";
import { useCallback, useImperativeHandle } from "react";
import type { ForwardedRef, RefObject } from "react";
import type { TimelineShortcutBindings } from "../core/timelineTypes";
import { useTimelineDndBindings } from "./useTimelineDndBindings";
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
import { useTimelineNormalization } from "./useTimelineNormalization";
import { useTimelineSelection } from "./useTimelineSelection";
import { useTimelineZoomActions } from "./actions/useTimelineZoomActions";
import type {
AnnotationRegion,
AudioRegion,
ClipRegion,
CursorTelemetryPoint,
SpeedRegion,
TrimRegion,
ZoomFocus,
ZoomRegion,
} from "../../types";
import type { TimelineEditorHandle } from "../TimelineEditor";
interface UseTimelineEditorRuntimeParams {
ref: ForwardedRef<TimelineEditorHandle>;
videoDuration: number;
totalMs: number;
currentTimeMs: number;
safeMinDurationMs: number;
cursorTelemetry: CursorTelemetryPoint[];
autoSuggestZoomsTrigger: number;
onAutoSuggestZoomsConsumed?: () => void;
disableSuggestedZooms: boolean;
zoomRegions: ZoomRegion[];
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
trimRegions: TrimRegion[];
onTrimSpanChange?: (id: string, span: Span) => void;
clipRegions: ClipRegion[];
onClipSplit?: (splitMs: number) => void;
onClipSpanChange?: (id: string, span: Span) => void;
onClipDelete?: (id: string) => void;
selectedClipId?: string | null;
onSelectClip?: (id: string | null) => void;
annotationRegions: AnnotationRegion[];
onAnnotationAdded?: (span: Span, trackIndex?: number) => void;
onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
speedRegions: SpeedRegion[];
onSpeedSpanChange?: (id: string, span: Span) => void;
audioRegions: AudioRegion[];
onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void;
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
isMac: boolean;
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject<boolean>;
}
export function useTimelineEditorRuntime({
ref,
videoDuration,
totalMs,
currentTimeMs,
safeMinDurationMs,
cursorTelemetry,
autoSuggestZoomsTrigger,
onAutoSuggestZoomsConsumed,
disableSuggestedZooms,
zoomRegions,
onZoomAdded,
onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
trimRegions,
onTrimSpanChange,
clipRegions,
onClipSplit,
onClipSpanChange,
onClipDelete,
selectedClipId,
onSelectClip,
annotationRegions,
onAnnotationAdded,
onAnnotationSpanChange,
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
speedRegions,
onSpeedSpanChange,
audioRegions,
onAudioAdded,
onAudioSpanChange,
onAudioDelete,
selectedAudioId,
onSelectAudio,
isMac,
keyShortcuts,
isTimelineFocusedRef,
}: UseTimelineEditorRuntimeParams) {
const {
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
hasAnyTimelineBlocks,
addKeyframe,
deleteSelectedKeyframe,
handleKeyframeMove,
deleteSelectedZoom,
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
clearSelectedBlocks,
deleteAllBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
cycleAnnotationsAtCurrentTime,
} = useTimelineSelection({
totalMs,
currentTimeMs,
zoomRegions,
clipRegions,
annotationRegions,
audioRegions,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
onZoomDelete,
onClipDelete,
onAnnotationDelete,
onAudioDelete,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
});
useTimelineNormalization({
totalMs,
safeMinDurationMs,
zoomRegions,
trimRegions,
speedRegions,
audioRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
});
const { hasOverlap, timelineItems, allRegionSpans, getResolvedDropRowId, handleItemSpanChange } =
useTimelineDndBindings({
zoomRegions,
trimRegions,
clipRegions,
annotationRegions,
speedRegions,
audioRegions,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
onAnnotationSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
});
const { defaultRegionDurationMs, canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, handleSuggestZooms } =
useTimelineZoomActions({
timeline: { videoDuration, totalMs, currentTimeMs },
regions: { zoom: zoomRegions, clip: clipRegions },
cursorTelemetry,
options: { disableSuggestedZooms },
autoSuggestZoomsTrigger,
onAutoSuggestZoomsConsumed,
onZoomAdded,
onZoomSuggested,
});
const handleSplitClip = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
return;
}
onClipSplit(currentTimeMs);
}, [videoDuration, totalMs, currentTimeMs, onClipSplit]);
const { handleAddAudio } = useTimelineAudioActions({
timeline: { videoDuration, totalMs, currentTimeMs },
regions: { audio: audioRegions },
onAudioAdded,
});
const handleAddAnnotation = useCallback(
(trackIndex = 0) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const latestStartPos = Math.max(0, totalMs - defaultDuration);
const startPos = Math.max(0, Math.min(currentTimeMs, latestStartPos));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
},
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
);
useTimelineKeyboardShortcuts({
isMac,
keyShortcuts,
isTimelineFocusedRef,
hasAnyTimelineBlocks,
annotationCount: annotationRegions.length,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
selectAllBlocksActive,
setSelectAllBlocksActive,
setSelectedKeyframeId,
addKeyframe,
handleAddZoom,
handleSplitClip,
handleAddAnnotation: () => handleAddAnnotation(),
deleteAllBlocks,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
cycleAnnotationsAtCurrentTime,
});
useImperativeHandle(
ref,
() => ({
addZoom: handleAddZoom,
suggestZooms: handleSuggestZooms,
splitClip: handleSplitClip,
addAnnotation: handleAddAnnotation,
addAudio: handleAddAudio,
keyframes,
}),
[handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes],
);
return {
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
handleKeyframeMove,
clearSelectedBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
hasOverlap,
timelineItems,
allRegionSpans,
getResolvedDropRowId,
handleItemSpanChange,
canPlaceZoomAtMs,
addZoomAtMs,
handleAddZoom,
handleSuggestZooms,
handleSplitClip,
handleAddAudio,
handleAddAnnotation,
};
}
@@ -0,0 +1,158 @@
import { useEffect, type RefObject } from "react";
import { matchesShortcut } from "@/lib/shortcuts";
import type { TimelineShortcutBindings } from "../core/timelineTypes";
import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils";
interface UseTimelineKeyboardShortcutsParams {
isMac: boolean;
keyShortcuts: TimelineShortcutBindings;
isTimelineFocusedRef: RefObject<boolean>;
hasAnyTimelineBlocks: boolean;
annotationCount: number;
selectedKeyframeId: string | null;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
selectAllBlocksActive: boolean;
setSelectAllBlocksActive: (active: boolean) => void;
setSelectedKeyframeId: (id: string | null) => void;
addKeyframe: () => void;
handleAddZoom: () => void;
handleSplitClip: () => void;
handleAddAnnotation: () => void;
deleteAllBlocks: () => void;
deleteSelectedKeyframe: () => void;
deleteSelectedZoom: () => void;
deleteSelectedClip: () => void;
deleteSelectedAnnotation: () => void;
deleteSelectedAudio: () => void;
cycleAnnotationsAtCurrentTime: (backward?: boolean) => boolean;
}
export function useTimelineKeyboardShortcuts({
isMac,
keyShortcuts,
isTimelineFocusedRef,
hasAnyTimelineBlocks,
annotationCount,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
selectAllBlocksActive,
setSelectAllBlocksActive,
setSelectedKeyframeId,
addKeyframe,
handleAddZoom,
handleSplitClip,
handleAddAnnotation,
deleteAllBlocks,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
cycleAnnotationsAtCurrentTime,
}: UseTimelineKeyboardShortcutsParams) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const eventTarget = e.target;
if (
eventTarget instanceof HTMLInputElement ||
eventTarget instanceof HTMLTextAreaElement ||
eventTarget instanceof HTMLSelectElement ||
(eventTarget instanceof HTMLElement && eventTarget.isContentEditable)
) {
return;
}
if (!isTimelineFocusedRef.current) {
return;
}
if (matchesShortcut(e, { key: "a", ctrl: true }, isMac)) {
if (!hasAnyTimelineBlocks) {
return;
}
e.preventDefault();
setSelectedKeyframeId(null);
setSelectAllBlocksActive(true);
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();
}
}
if (
e.key === "Delete" ||
e.key === "Backspace" ||
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
) {
const target = resolveDeleteSelectionTarget({
selectAllBlocksActive,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
});
if (target !== "none") {
e.preventDefault();
}
if (target === "all") {
deleteAllBlocks();
} else if (target === "keyframe") {
deleteSelectedKeyframe();
} else if (target === "zoom") {
deleteSelectedZoom();
} else if (target === "clip") {
deleteSelectedClip();
} else if (target === "annotation") {
deleteSelectedAnnotation();
} else if (target === "audio") {
deleteSelectedAudio();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
addKeyframe,
annotationCount,
cycleAnnotationsAtCurrentTime,
deleteAllBlocks,
deleteSelectedAnnotation,
deleteSelectedAudio,
deleteSelectedClip,
deleteSelectedKeyframe,
deleteSelectedZoom,
handleAddAnnotation,
handleAddZoom,
handleSplitClip,
hasAnyTimelineBlocks,
isMac,
isTimelineFocusedRef,
keyShortcuts,
selectAllBlocksActive,
selectedAnnotationId,
selectedAudioId,
selectedClipId,
selectedKeyframeId,
selectedZoomId,
setSelectAllBlocksActive,
setSelectedKeyframeId,
]);
}
@@ -0,0 +1,98 @@
import { useEffect } from "react";
import { normalizeRegionSpan } from "../core/spans";
import type { AudioRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../../types";
interface UseTimelineNormalizationParams {
totalMs: number;
safeMinDurationMs: number;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
audioRegions: AudioRegion[];
onZoomSpanChange: (id: string, span: { start: number; end: number }) => void;
onTrimSpanChange?: (id: string, span: { start: number; end: number }) => void;
onSpeedSpanChange?: (id: string, span: { start: number; end: number }) => void;
onAudioSpanChange?: (id: string, span: { start: number; end: number }) => void;
}
export function useTimelineNormalization({
totalMs,
safeMinDurationMs,
zoomRegions,
trimRegions,
speedRegions,
audioRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
}: UseTimelineNormalizationParams) {
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
return;
}
zoomRegions.forEach((region) => {
const normalized = normalizeRegionSpan({
startMs: region.startMs,
endMs: region.endMs,
totalMs,
minDurationMs: safeMinDurationMs,
});
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
onZoomSpanChange(region.id, normalized);
}
});
trimRegions.forEach((region) => {
const normalized = normalizeRegionSpan({
startMs: region.startMs,
endMs: region.endMs,
totalMs,
minDurationMs: safeMinDurationMs,
});
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
onTrimSpanChange?.(region.id, normalized);
}
});
speedRegions.forEach((region) => {
const normalized = normalizeRegionSpan({
startMs: region.startMs,
endMs: region.endMs,
totalMs,
minDurationMs: safeMinDurationMs,
});
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
onSpeedSpanChange?.(region.id, normalized);
}
});
audioRegions.forEach((region) => {
const normalized = normalizeRegionSpan({
startMs: region.startMs,
endMs: region.endMs,
totalMs,
minDurationMs: safeMinDurationMs,
});
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
onAudioSpanChange?.(region.id, normalized);
}
});
}, [
totalMs,
safeMinDurationMs,
zoomRegions,
trimRegions,
speedRegions,
audioRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAudioSpanChange,
]);
}
@@ -0,0 +1,80 @@
import type { Range } from "dnd-timeline";
import { useCallback, useEffect, useMemo, useState, type RefObject, type WheelEvent } from "react";
import { createInitialRange, normalizeWheelDeltaToPixels } from "../core/time";
interface UseTimelineRangeParams {
totalMs: number;
timelineContainerRef: RefObject<HTMLDivElement>;
}
export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) {
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
return range;
}
return {
start: Math.max(0, Math.min(range.start, totalMs)),
end: Math.min(range.end, totalMs),
};
}, [range, totalMs]);
const panTimelineRange = useCallback(
(deltaMs: number) => {
if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
return;
}
setRange((previous) => {
const visibleSpan = Math.max(1, previous.end - previous.start);
const maxStart = Math.max(0, totalMs - visibleSpan);
const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart));
return { start: nextStart, end: nextStart + visibleSpan };
});
},
[totalMs],
);
const handleTimelineWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (event.ctrlKey || event.metaKey || totalMs <= 0) {
return;
}
const rawHorizontalDelta =
Math.abs(event.deltaX) > 0
? event.deltaX
: event.shiftKey && Math.abs(event.deltaY) > 0
? event.deltaY
: 0;
if (rawHorizontalDelta === 0) {
return;
}
const containerWidth = timelineContainerRef.current?.clientWidth ?? 0;
const visibleRangeMs = clampedRange.end - clampedRange.start;
if (containerWidth <= 0 || visibleRangeMs <= 0) {
return;
}
event.preventDefault();
const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode);
const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs;
panTimelineRange(deltaMs);
},
[clampedRange.end, clampedRange.start, panTimelineRange, timelineContainerRef, totalMs],
);
return {
range,
setRange,
clampedRange,
handleTimelineWheel,
};
}
@@ -0,0 +1,212 @@
import { useCallback, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import type { TimelineRegion } from "../core/timelineTypes";
interface UseTimelineSelectionParams {
totalMs: number;
currentTimeMs: number;
zoomRegions: TimelineRegion[];
clipRegions: TimelineRegion[];
annotationRegions: (TimelineRegion & { zIndex: number })[];
audioRegions: TimelineRegion[];
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
onZoomDelete: (id: string) => void;
onClipDelete?: (id: string) => void;
onAnnotationDelete?: (id: string) => void;
onAudioDelete?: (id: string) => void;
onSelectZoom: (id: string | null) => void;
onSelectClip?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectAudio?: (id: string | null) => void;
}
export function useTimelineSelection({
totalMs,
currentTimeMs,
zoomRegions,
clipRegions,
annotationRegions,
audioRegions,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
onZoomDelete,
onClipDelete,
onAnnotationDelete,
onAudioDelete,
onSelectZoom,
onSelectClip,
onSelectAnnotation,
onSelectAudio,
}: UseTimelineSelectionParams) {
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [selectAllBlocksActive, setSelectAllBlocksActive] = useState(false);
const addKeyframe = useCallback(() => {
if (totalMs === 0) return;
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
if (keyframes.some((kf) => Math.abs(kf.time - time) < 1)) return;
setKeyframes((prev) => [...prev, { id: uuidv4(), time }]);
}, [currentTimeMs, totalMs, keyframes]);
const deleteSelectedKeyframe = useCallback(() => {
if (!selectedKeyframeId) return;
setKeyframes((prev) => prev.filter((kf) => kf.id !== selectedKeyframeId));
setSelectedKeyframeId(null);
}, [selectedKeyframeId]);
const handleKeyframeMove = useCallback(
(id: string, newTime: number) => {
setKeyframes((prev) =>
prev.map((kf) =>
kf.id === id ? { ...kf, time: Math.max(0, Math.min(newTime, totalMs)) } : kf,
),
);
},
[totalMs],
);
const deleteSelectedZoom = useCallback(() => {
if (!selectedZoomId) return;
onZoomDelete(selectedZoomId);
onSelectZoom(null);
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
const deleteSelectedClip = useCallback(() => {
if (!selectedClipId || !onClipDelete || !onSelectClip) return;
onClipDelete(selectedClipId);
onSelectClip(null);
}, [selectedClipId, onClipDelete, onSelectClip]);
const deleteSelectedAnnotation = useCallback(() => {
if (!selectedAnnotationId || !onAnnotationDelete || !onSelectAnnotation) return;
onAnnotationDelete(selectedAnnotationId);
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedAudio = useCallback(() => {
if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
onAudioDelete(selectedAudioId);
onSelectAudio(null);
}, [selectedAudioId, onAudioDelete, onSelectAudio]);
const clearSelectedBlocks = useCallback(() => {
onSelectZoom(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
setSelectAllBlocksActive(false);
}, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]);
const hasAnyTimelineBlocks = useMemo(
() =>
zoomRegions.length > 0 ||
clipRegions.length > 0 ||
annotationRegions.length > 0 ||
audioRegions.length > 0,
[zoomRegions.length, clipRegions.length, annotationRegions.length, audioRegions.length],
);
const deleteAllBlocks = useCallback(() => {
zoomRegions.map((r) => r.id).forEach((id) => onZoomDelete(id));
clipRegions.map((r) => r.id).forEach((id) => onClipDelete?.(id));
annotationRegions.map((r) => r.id).forEach((id) => onAnnotationDelete?.(id));
audioRegions.map((r) => r.id).forEach((id) => onAudioDelete?.(id));
clearSelectedBlocks();
setSelectedKeyframeId(null);
}, [
zoomRegions,
clipRegions,
annotationRegions,
audioRegions,
onZoomDelete,
onClipDelete,
onAnnotationDelete,
onAudioDelete,
clearSelectedBlocks,
]);
const handleSelectZoom = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectZoom(id);
},
[onSelectZoom],
);
const handleSelectClip = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectClip?.(id);
},
[onSelectClip],
);
const handleSelectAnnotation = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectAnnotation?.(id);
},
[onSelectAnnotation],
);
const handleSelectAudio = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectAudio?.(id);
},
[onSelectAudio],
);
const cycleAnnotationsAtCurrentTime = useCallback(
(backward = false) => {
const overlapping = annotationRegions
.filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
.sort((a, b) => a.zIndex - b.zIndex);
if (overlapping.length === 0) {
return false;
}
if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) {
onSelectAnnotation?.(overlapping[0].id);
return true;
}
const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId);
const nextIndex = backward
? (currentIndex - 1 + overlapping.length) % overlapping.length
: (currentIndex + 1) % overlapping.length;
onSelectAnnotation?.(overlapping[nextIndex].id);
return true;
},
[annotationRegions, currentTimeMs, selectedAnnotationId, onSelectAnnotation],
);
return {
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
selectAllBlocksActive,
setSelectAllBlocksActive,
hasAnyTimelineBlocks,
addKeyframe,
deleteSelectedKeyframe,
handleKeyframeMove,
deleteSelectedZoom,
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedAudio,
clearSelectedBlocks,
deleteAllBlocks,
handleSelectZoom,
handleSelectClip,
handleSelectAnnotation,
handleSelectAudio,
cycleAnnotationsAtCurrentTime,
};
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { resolveAudioPlacement } from "./timelineAudioPlacement";
describe("timelineAudioPlacement", () => {
it("uses first available track when no preferred track is provided", () => {
const placement = resolveAudioPlacement({
audioRegions: [{ id: "a1", startMs: 0, endMs: 500, trackIndex: 0 }],
startPos: 500,
totalMs: 2000,
audioDurationMs: 500,
});
expect(placement).toEqual({ trackIndex: 0, durationMs: 500 });
});
it("falls back to next track when preferred track is blocked", () => {
const placement = resolveAudioPlacement({
audioRegions: [{ id: "a1", startMs: 0, endMs: 1500, trackIndex: 0 }],
startPos: 1000,
totalMs: 3000,
audioDurationMs: 800,
});
expect(placement).toEqual({ trackIndex: 1, durationMs: 800 });
});
it("returns null when no slot is available", () => {
const placement = resolveAudioPlacement({
audioRegions: [{ id: "a1", startMs: 0, endMs: 2000, trackIndex: 0 }],
startPos: 1500,
totalMs: 2000,
audioDurationMs: 800,
preferredTrackIndex: 0,
});
expect(placement).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { spansOverlap } from "../../core/spans";
import type { TimelineAudioRegion } from "../../core/timelineTypes";
interface ResolveAudioPlacementParams {
audioRegions: TimelineAudioRegion[];
startPos: number;
totalMs: number;
audioDurationMs: number;
preferredTrackIndex?: number;
}
interface AudioPlacement {
trackIndex: number;
durationMs: number;
}
export function resolveAudioPlacement({
audioRegions,
startPos,
totalMs,
audioDurationMs,
preferredTrackIndex,
}: ResolveAudioPlacementParams): AudioPlacement | null {
const maxRemainingDuration = totalMs - startPos;
if (audioDurationMs <= 0 || maxRemainingDuration <= 0) {
return null;
}
const desiredDuration = Math.min(audioDurationMs, maxRemainingDuration);
const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex)
? Math.max(0, Math.floor(preferredTrackIndex ?? 0))
: null;
const maxTrackIndex = audioRegions.reduce((max, region) => Math.max(max, region.trackIndex ?? 0), -1);
const candidateTrackIndexes =
normalizedPreferredTrackIndex === null
? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index)
: [normalizedPreferredTrackIndex];
const getGapForTrack = (trackIndex: number) => {
const trackRegions = audioRegions
.filter((region) => (region.trackIndex ?? 0) === trackIndex)
.sort((left, right) => left.startMs - right.startMs);
const desiredSpan = {
start: startPos,
end: startPos + desiredDuration,
};
const overlappingRegion = trackRegions.find((region) =>
spansOverlap(desiredSpan, { start: region.startMs, end: region.endMs }),
);
if (overlappingRegion) {
return 0;
}
const nextRegion = trackRegions.find((region) => region.startMs > startPos);
return nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
};
let selectedTrackIndex: number | null = null;
let availableGap = 0;
for (const trackIndex of candidateTrackIndexes) {
const gap = getGapForTrack(trackIndex);
if (gap >= desiredDuration) {
selectedTrackIndex = trackIndex;
availableGap = gap;
break;
}
}
if (selectedTrackIndex === null && normalizedPreferredTrackIndex === null) {
for (const trackIndex of candidateTrackIndexes) {
const gap = getGapForTrack(trackIndex);
if (gap > 0) {
selectedTrackIndex = trackIndex;
availableGap = gap;
break;
}
}
}
if (selectedTrackIndex === null || availableGap <= 0) {
return null;
}
return {
trackIndex: selectedTrackIndex,
durationMs: Math.min(audioDurationMs, availableGap, totalMs - startPos),
};
}
@@ -0,0 +1,13 @@
import { toast } from "sonner";
export interface TimelineNotifications {
error: (title: string, description?: string) => void;
info: (title: string, description?: string) => void;
success: (title: string, description?: string) => void;
}
export const timelineNotifications: TimelineNotifications = {
error: (title, description) => toast.error(title, description ? { description } : undefined),
info: (title, description) => toast.info(title, description ? { description } : undefined),
success: (title, description) => toast.success(title, description ? { description } : undefined),
};
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { resolveDeleteSelectionTarget } from "./timelineSelectionUtils";
describe("timelineSelectionUtils", () => {
it("prioritizes select-all over any individual selection", () => {
expect(
resolveDeleteSelectionTarget({
selectAllBlocksActive: true,
selectedKeyframeId: "kf-1",
selectedZoomId: "z-1",
selectedClipId: "c-1",
selectedAnnotationId: "a-1",
selectedAudioId: "au-1",
}),
).toBe("all");
});
it("follows selection priority order", () => {
expect(
resolveDeleteSelectionTarget({
selectAllBlocksActive: false,
selectedKeyframeId: "kf-1",
selectedZoomId: "z-1",
}),
).toBe("keyframe");
expect(
resolveDeleteSelectionTarget({
selectAllBlocksActive: false,
selectedKeyframeId: null,
selectedZoomId: "z-1",
selectedClipId: "c-1",
}),
).toBe("zoom");
expect(
resolveDeleteSelectionTarget({
selectAllBlocksActive: false,
selectedKeyframeId: null,
selectedZoomId: null,
selectedClipId: "c-1",
selectedAnnotationId: "a-1",
}),
).toBe("clip");
});
it("returns none when nothing is selected", () => {
expect(
resolveDeleteSelectionTarget({
selectAllBlocksActive: false,
selectedKeyframeId: null,
selectedZoomId: null,
}),
).toBe("none");
});
});
@@ -0,0 +1,34 @@
export type DeleteSelectionTarget =
| "all"
| "keyframe"
| "zoom"
| "clip"
| "annotation"
| "audio"
| "none";
interface ResolveDeleteSelectionTargetParams {
selectAllBlocksActive: boolean;
selectedKeyframeId: string | null;
selectedZoomId: string | null;
selectedClipId?: string | null;
selectedAnnotationId?: string | null;
selectedAudioId?: string | null;
}
export function resolveDeleteSelectionTarget({
selectAllBlocksActive,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
}: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget {
if (selectAllBlocksActive) return "all";
if (selectedKeyframeId) return "keyframe";
if (selectedZoomId) return "zoom";
if (selectedClipId) return "clip";
if (selectedAnnotationId) return "annotation";
if (selectedAudioId) return "audio";
return "none";
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildAllRegionSpans,
buildTimelineItems,
getAnnotationLabel,
getAudioLabel,
resolveDropRowId,
} from "./timelineModel";
const BASE_ANNOTATION = {
id: "a1",
startMs: 200,
endMs: 1200,
position: { x: 0, y: 0 },
size: { width: 1, height: 1 },
style: {
fontSize: 12,
color: "#fff",
backgroundColor: "transparent",
borderRadius: 0,
fontFamily: "Inter",
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
textAlign: "left" as const,
},
zIndex: 0,
};
describe("timeline model", () => {
it("maps regions to timeline items and labels", () => {
const items = buildTimelineItems({
zoomRegions: [
{ id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
],
clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }],
annotationRegions: [
{ ...BASE_ANNOTATION, type: "text" as const, content: "Hello timeline", trackIndex: 1 },
],
audioRegions: [
{ id: "au1", startMs: 500, endMs: 2000, audioPath: "/tmp/foo.mp3", volume: 1, trackIndex: 0 },
],
});
expect(items).toHaveLength(4);
expect(items.find((i) => i.id === "a1")?.rowId).toBe("row-annotation-1");
expect(items.find((i) => i.id === "au1")?.label).toBe("foo");
});
it("builds all variant labels for annotation and audio", () => {
expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe(
"Empty text",
);
expect(
getAnnotationLabel({
...BASE_ANNOTATION,
type: "text",
content: "abcdefghijklmnopqrstuvwxyz",
}),
).toBe("abcdefghijklmnopqrst...");
expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "image", content: "x" })).toBe(
"Image",
);
expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "figure", content: "x" })).toBe(
"Annotation",
);
expect(getAudioLabel({ id: "1", startMs: 0, endMs: 1, audioPath: "C:\\x\\y\\z.wav", volume: 1 })).toBe("z");
expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe("Audio");
});
it("builds row spans for dnd constraints", () => {
const spans = buildAllRegionSpans({
zoomRegions: [
{ id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
],
clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }],
audioRegions: [
{ id: "au1", startMs: 500, endMs: 2000, audioPath: "x.wav", volume: 1, trackIndex: 2 },
],
});
expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]);
});
it("keeps items in their domain rows during dnd", () => {
const items = [
{ id: "a1", rowId: "row-annotation-1", span: { start: 0, end: 1 }, label: "A", variant: "annotation" as const },
{ id: "au1", rowId: "row-audio-2", span: { start: 0, end: 1 }, label: "X", variant: "audio" as const },
{ id: "z1", rowId: "row-zoom", span: { start: 0, end: 1 }, label: "Z", variant: "zoom" as const },
];
expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1");
expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3");
expect(resolveDropRowId("au1", "row-annotation-1", items)).toBe("row-audio-2");
expect(resolveDropRowId("au1", "row-audio-7", items)).toBe("row-audio-7");
expect(resolveDropRowId("z1", "row-audio-1", items)).toBe("row-zoom");
expect(resolveDropRowId("unknown", "row-audio-1", items)).toBe("row-audio-1");
});
});
@@ -0,0 +1,127 @@
import type {
AnnotationRegion,
AudioRegion,
ClipRegion,
ZoomRegion,
} from "../../types";
import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
getAudioTrackIndex,
getAudioTrackRowId,
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../core/rows";
export function getAnnotationLabel(region: AnnotationRegion): string {
if (region.type === "text") {
const preview = region.content.trim() || "Empty text";
return preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
}
if (region.type === "image") {
return "Image";
}
return "Annotation";
}
export function getAudioLabel(region: AudioRegion): string {
return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio";
}
export function buildTimelineItems(params: {
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
annotationRegions: AnnotationRegion[];
audioRegions: AudioRegion[];
}): TimelineRenderItem[] {
const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params;
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
id: region.id,
rowId: ZOOM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
zoomDepth: region.depth,
zoomMode: region.mode ?? "auto",
variant: "zoom",
}));
const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({
id: region.id,
rowId: CLIP_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Clip ${index + 1}`,
variant: "clip",
}));
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => ({
id: region.id,
rowId: getAnnotationTrackRowId(region.trackIndex ?? 0),
span: { start: region.startMs, end: region.endMs },
label: getAnnotationLabel(region),
variant: "annotation",
}));
const audios: TimelineRenderItem[] = audioRegions.map((region) => ({
id: region.id,
rowId: getAudioTrackRowId(region.trackIndex ?? 0),
span: { start: region.startMs, end: region.endMs },
label: getAudioLabel(region),
variant: "audio",
}));
return [...zooms, ...clips, ...annotations, ...audios];
}
export function buildAllRegionSpans(params: {
zoomRegions: ZoomRegion[];
clipRegions: ClipRegion[];
audioRegions: AudioRegion[];
}): TimelineRegionSpan[] {
const { zoomRegions, clipRegions, audioRegions } = params;
const zooms = zoomRegions.map((r) => ({
id: r.id,
start: r.startMs,
end: r.endMs,
rowId: ZOOM_ROW_ID,
}));
const clips = clipRegions.map((r) => ({
id: r.id,
start: r.startMs,
end: r.endMs,
rowId: CLIP_ROW_ID,
}));
const audios = audioRegions.map((r) => ({
id: r.id,
start: r.startMs,
end: r.endMs,
rowId: getAudioTrackRowId(r.trackIndex ?? 0),
}));
return [...zooms, ...clips, ...audios];
}
export function resolveDropRowId(
id: string,
proposedRowId: string,
timelineItems: TimelineRenderItem[],
) {
const currentRowId = timelineItems.find((item) => item.id === id)?.rowId;
if (!currentRowId) {
return proposedRowId;
}
if (isAnnotationTrackRowId(currentRowId)) {
return isAnnotationTrackRowId(proposedRowId)
? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId))
: currentRowId;
}
if (isAudioTrackRowId(currentRowId)) {
return isAudioTrackRowId(proposedRowId)
? getAudioTrackRowId(getAudioTrackIndex(proposedRowId))
: currentRowId;
}
return currentRowId;
}