mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
Merge branch 'main' into feat/settings-panel-auto-props-extension-ui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-8
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user