removed useless files, to avoid having more files than needed

This commit is contained in:
Alan Trebugeais
2026-05-08 14:44:26 +02:00
parent a38cdb98c1
commit 83e2024732
9 changed files with 497 additions and 538 deletions
@@ -1,4 +1,5 @@
import type { Span } from "dnd-timeline";
import { Plus } from "@phosphor-icons/react";
import {
forwardRef,
type KeyboardEvent as ReactKeyboardEvent,
@@ -32,7 +33,6 @@ import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks";
import { calculateTimelineScale } from "./core/time";
import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime";
import { useTimelineRange } from "./hooks/useTimelineRange";
import TimelineEditorShell from "./components/editor/TimelineEditorShell";
import TimelineCanvas from "./components/viewport/TimelineCanvas";
import TimelineToolbar from "./components/toolbar/TimelineToolbar";
@@ -300,11 +300,25 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
void handleAddAudio();
}, [handleAddAudio]);
if (!videoDuration || videoDuration === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
<Plus className="w-6 h-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Drag and drop a video to start editing
</p>
</div>
</div>
);
}
return (
<TimelineEditorShell
videoDuration={videoDuration}
hideToolbar={hideToolbar}
toolbar={
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : (
<TimelineToolbar
aspectRatio={aspectRatio}
isCropped={isCropped}
@@ -324,9 +338,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSplitClip={handleSplitClip}
cropLabel={t("sections.crop", "Crop")}
/>
}
timelineViewport={
<div
)}
<div
ref={timelineContainerRef}
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
tabIndex={0}
@@ -387,8 +400,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
/>
</TimelineWrapper>
</div>
}
/>
</div>
);
},
);
@@ -1,39 +0,0 @@
import { Plus } from "@phosphor-icons/react";
import type { ReactNode } from "react";
interface TimelineEditorShellProps {
videoDuration: number;
hideToolbar: boolean;
toolbar: ReactNode;
timelineViewport: ReactNode;
}
export default function TimelineEditorShell({
videoDuration,
hideToolbar,
toolbar,
timelineViewport,
}: TimelineEditorShellProps) {
if (!videoDuration || videoDuration === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
<Plus className="w-6 h-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Drag and drop a video to start editing
</p>
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : toolbar}
{timelineViewport}
</div>
);
}
@@ -1,18 +1,34 @@
import { Plus } from "@phosphor-icons/react";
import { useTimelineContext } from "dnd-timeline";
import { useCallback, useMemo, useRef } from "react";
import type { MouseEvent } from "react";
import { memo, useCallback, 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 { isAnnotationTrackRowId, isAudioTrackRowId } from "../../core/rows";
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";
import { TimelineCanvasRows } from "./TimelineCanvasRows";
import { useTimelineCanvasHover } from "./useTimelineCanvasHover";
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[];
@@ -35,6 +51,359 @@ interface TimelineCanvasProps {
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,
@@ -71,11 +440,14 @@ export default function TimelineCanvas({
(e: MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
onSelectZoom?.(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
onClearBlockSelection?.();
if (onClearBlockSelection) {
onClearBlockSelection();
} else {
onSelectZoom?.(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectAudio?.(null);
}
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -125,7 +497,7 @@ export default function TimelineCanvas({
handleZoomRowMouseMove,
handleZoomRowMouseLeave,
handleZoomRowClick,
} = useTimelineCanvasHover({
} = useTimelineHover({
direction,
sidebarWidth,
rangeStart: range.start,
@@ -1,245 +0,0 @@
import { Plus } from "@phosphor-icons/react";
import { memo, useMemo, type MouseEventHandler } from "react";
import { cn } from "@/lib/utils";
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 ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
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 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>;
}
function TimelineCanvasRowsComponent({
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) => {
return (
<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) => {
return (
<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 const TimelineCanvasRows = memo(TimelineCanvasRowsComponent);
@@ -1,144 +0,0 @@
import { useCallback, useState, type MouseEvent } from "react";
interface UseTimelineCanvasHoverParams {
direction: string;
sidebarWidth: number;
rangeStart: number;
rangeEnd: number;
videoDurationMs: number;
onAddZoomAtMs?: (startMs: number) => void;
canPlaceZoomAtMs?: (startMs: number) => boolean;
valueToPixels: (value: number) => number;
}
export function useTimelineCanvasHover({
direction,
sidebarWidth,
rangeStart,
rangeEnd,
videoDurationMs,
onAddZoomAtMs,
canPlaceZoomAtMs,
valueToPixels,
}: UseTimelineCanvasHoverParams) {
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,
};
}
@@ -1,37 +0,0 @@
import { useCallback } from "react";
interface UseTimelineAnnotationsActionsParams {
videoDuration: number;
totalMs: number;
currentTimeMs: number;
defaultRegionDurationMs: number;
onAnnotationAdded?: (span: { start: number; end: number }, trackIndex?: number) => void;
}
export function useTimelineAnnotationsActions({
videoDuration,
totalMs,
currentTimeMs,
defaultRegionDurationMs,
onAnnotationAdded,
}: UseTimelineAnnotationsActionsParams) {
const handleAddAnnotation = useCallback(
(trackIndex = 0) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
},
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
);
return { handleAddAnnotation };
}
@@ -28,6 +28,8 @@ interface UseTimelineDndBindingsParams {
onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void;
}
type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null;
export function useTimelineDndBindings({
zoomRegions,
trimRegions,
@@ -42,16 +44,39 @@ export function useTimelineDndBindings({
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 => {
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isClipItem = clipRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
const isAudioItem = audioRegions.some((r) => r.id === excludeId);
if (!excludeId) return false;
const itemKind = resolveItemKind(excludeId);
if (isAnnotationItem) return false;
if (itemKind === "annotation") return false;
const checkOverlap = (
regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[],
@@ -61,17 +86,13 @@ export function useTimelineDndBindings({
return spansOverlap(newSpan, { start: region.startMs, end: region.endMs });
});
if (isZoomItem) return checkOverlap(zoomRegions);
if (isTrimItem) return checkOverlap(trimRegions);
if (isClipItem) return checkOverlap(clipRegions);
if (isSpeedItem) return checkOverlap(speedRegions);
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 (isAudioItem) {
const activeAudioRegion = audioRegions.find((region) => region.id === excludeId);
const activeTrackIndex =
rowId && isAudioTrackRowId(rowId)
? getAudioTrackIndex(rowId)
: (activeAudioRegion?.trackIndex ?? 0);
if (itemKind === "audio") {
const activeTrackIndex = resolveTrackIndex("audio", excludeId, rowId);
return checkOverlap(
audioRegions.filter((region) => (region.trackIndex ?? 0) === activeTrackIndex),
);
@@ -79,7 +100,15 @@ export function useTimelineDndBindings({
return false;
},
[zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions],
[
resolveItemKind,
resolveTrackIndex,
zoomRegions,
trimRegions,
clipRegions,
audioRegions,
speedRegions,
],
);
const timelineItems = useMemo<TimelineRenderItem[]>(
@@ -110,35 +139,26 @@ export function useTimelineDndBindings({
const handleItemSpanChange = useCallback(
(id: string, span: Span, rowId?: string) => {
if (zoomRegions.some((r) => r.id === id)) {
const itemKind = resolveItemKind(id);
if (itemKind === "zoom") {
onZoomSpanChange(id, span);
} else if (trimRegions.some((r) => r.id === id)) {
} else if (itemKind === "trim") {
onTrimSpanChange?.(id, span);
} else if (clipRegions.some((r) => r.id === id)) {
} else if (itemKind === "clip") {
onClipSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
const nextTrackIndex =
rowId && isAnnotationTrackRowId(rowId)
? getAnnotationTrackIndex(rowId)
: (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0);
} else if (itemKind === "annotation") {
const nextTrackIndex = resolveTrackIndex("annotation", id, rowId);
onAnnotationSpanChange?.(id, span, nextTrackIndex);
} else if (speedRegions.some((r) => r.id === id)) {
} else if (itemKind === "speed") {
onSpeedSpanChange?.(id, span);
} else if (audioRegions.some((r) => r.id === id)) {
const nextTrackIndex =
rowId && isAudioTrackRowId(rowId)
? getAudioTrackIndex(rowId)
: (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0);
} else if (itemKind === "audio") {
const nextTrackIndex = resolveTrackIndex("audio", id, rowId);
onAudioSpanChange?.(id, span, nextTrackIndex);
}
},
[
zoomRegions,
trimRegions,
clipRegions,
annotationRegions,
speedRegions,
audioRegions,
resolveItemKind,
resolveTrackIndex,
onZoomSpanChange,
onTrimSpanChange,
onClipSpanChange,
@@ -3,7 +3,6 @@ import { useCallback, useImperativeHandle } from "react";
import type { ForwardedRef, RefObject } from "react";
import type { TimelineShortcutBindings } from "../core/timelineTypes";
import { useTimelineDndBindings } from "./useTimelineDndBindings";
import { useTimelineAnnotationsActions } from "./actions/useTimelineAnnotationsActions";
import { useTimelineAudioActions } from "./actions/useTimelineAudioActions";
import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts";
import { useTimelineNormalization } from "./useTimelineNormalization";
@@ -204,13 +203,23 @@ export function useTimelineEditorRuntime({
onAudioAdded,
});
const { handleAddAnnotation } = useTimelineAnnotationsActions({
videoDuration,
totalMs,
currentTimeMs,
defaultRegionDurationMs,
onAnnotationAdded,
});
const handleAddAnnotation = useCallback(
(trackIndex = 0) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onAnnotationAdded({ start: startPos, end: endPos }, trackIndex);
},
[videoDuration, totalMs, currentTimeMs, defaultRegionDurationMs, onAnnotationAdded],
);
useTimelineKeyboardShortcuts({
isMac,
@@ -1,6 +1,7 @@
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;
@@ -89,18 +90,28 @@ export function useTimelineKeyboardShortcuts({
e.key === "Backspace" ||
matchesShortcut(e, keyShortcuts.deleteSelected, isMac)
) {
if (selectAllBlocksActive) {
const target = resolveDeleteSelectionTarget({
selectAllBlocksActive,
selectedKeyframeId,
selectedZoomId,
selectedClipId,
selectedAnnotationId,
selectedAudioId,
});
if (target !== "none") {
e.preventDefault();
}
if (target === "all") {
deleteAllBlocks();
} else if (selectedKeyframeId) {
} else if (target === "keyframe") {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
} else if (target === "zoom") {
deleteSelectedZoom();
} else if (selectedClipId) {
} else if (target === "clip") {
deleteSelectedClip();
} else if (selectedAnnotationId) {
} else if (target === "annotation") {
deleteSelectedAnnotation();
} else if (selectedAudioId) {
} else if (target === "audio") {
deleteSelectedAudio();
}
}