From 83e20247325262a14007afdd2c8b81589aa2b0f6 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Fri, 8 May 2026 14:44:26 +0200 Subject: [PATCH] removed useless files, to avoid having more files than needed --- .../video-editor/timeline/TimelineEditor.tsx | 32 +- .../components/editor/TimelineEditorShell.tsx | 39 -- .../components/viewport/TimelineCanvas.tsx | 394 +++++++++++++++++- .../viewport/TimelineCanvasRows.tsx | 245 ----------- .../viewport/useTimelineCanvasHover.ts | 144 ------- .../actions/useTimelineAnnotationsActions.ts | 37 -- .../timeline/hooks/useTimelineDndBindings.ts | 96 +++-- .../hooks/useTimelineEditorRuntime.ts | 25 +- .../hooks/useTimelineKeyboardShortcuts.ts | 23 +- 9 files changed, 497 insertions(+), 538 deletions(-) delete mode 100644 src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx delete mode 100644 src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx delete mode 100644 src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts delete mode 100644 src/components/video-editor/timeline/hooks/actions/useTimelineAnnotationsActions.ts diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 1beb0bfc..dd3d07cf 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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( void handleAddAudio(); }, [handleAddAudio]); + if (!videoDuration || videoDuration === 0) { + return ( +
+
+ +
+
+

No Video Loaded

+

+ Drag and drop a video to start editing +

+
+
+ ); + } + return ( - + {hideToolbar ? null : ( ( onSplitClip={handleSplitClip} cropLabel={t("sections.crop", "Crop")} /> - } - timelineViewport={ -
( />
- } - /> + ); }, ); diff --git a/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx b/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx deleted file mode 100644 index 0c4e7e9c..00000000 --- a/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx +++ /dev/null @@ -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 ( -
-
- -
-
-

No Video Loaded

-

- Drag and drop a video to start editing -

-
-
- ); - } - - return ( -
- {hideToolbar ? null : toolbar} - {timelineViewport} -
- ); -} diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index c268322b..c176b642 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -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(null); + const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); + const [zoomRowHoverMs, setZoomRowHoverMs] = useState(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) => { + setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateTimelineHoverTime], + ); + + const handleTimelineMouseMove = useCallback( + (event: MouseEvent) => { + 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) => { + setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateZoomRowHoverTime], + ); + + const handleZoomRowMouseMove = useCallback( + (event: MouseEvent) => { + if (!isZoomRowHovered) setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isZoomRowHovered, updateZoomRowHoverTime], + ); + + const handleZoomRowMouseLeave = useCallback(() => { + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + + const handleZoomRowClick = useCallback( + (event: MouseEvent) => { + 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; + onZoomRowMouseMove: MouseEventHandler; + onZoomRowMouseLeave: MouseEventHandler; + onZoomRowClick: MouseEventHandler; +} + +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(); + const audioBuckets = new Map(); + + 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 ( + <> + + {audioPeaks && } + + {clipItems.map((item) => ( + + {item.label} + + ))} + + + + {canShowGhostZoom && ghostStartMs !== null && ( +
+
+
+
+
+
+ +
+
+
+
+ )} + {zoomItems.map((item) => ( + + {item.label} + + ))} + + + {annotationRows.map(({ rowId, items: rowItems }, index) => ( + + {rowItems.map((item) => ( + + {item.label} + + ))} + + ))} + + {audioRows.map(({ rowId, items: rowItems }, index) => ( + + {rowItems.map((item) => ( + + {item.label} + + ))} + + ))} + + ); +}); + export default function TimelineCanvas({ items, videoDurationMs, @@ -71,11 +440,14 @@ export default function TimelineCanvas({ (e: MouseEvent) => { 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, diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx deleted file mode 100644 index 4dd1f107..00000000 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx +++ /dev/null @@ -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; - onZoomRowMouseMove: MouseEventHandler; - onZoomRowMouseLeave: MouseEventHandler; - onZoomRowClick: MouseEventHandler; -} - -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(); - const audioBuckets = new Map(); - - 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 ( - <> - - {audioPeaks && } - - {clipItems.map((item) => ( - - {item.label} - - ))} - - - - {canShowGhostZoom && ghostStartMs !== null && ( -
-
-
-
-
-
- -
-
-
-
- )} - {zoomItems.map((item) => ( - - {item.label} - - ))} - - - {annotationRows.map(({ rowId, items: rowItems }, index) => { - return ( - - {rowItems.map((item) => ( - - {item.label} - - ))} - - ); - })} - - {audioRows.map(({ rowId, items: rowItems }, index) => { - return ( - - {rowItems.map((item) => ( - - {item.label} - - ))} - - ); - })} - - ); -} - -export const TimelineCanvasRows = memo(TimelineCanvasRowsComponent); diff --git a/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts b/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts deleted file mode 100644 index 5c9bd40c..00000000 --- a/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts +++ /dev/null @@ -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(null); - const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); - const [zoomRowHoverMs, setZoomRowHoverMs] = useState(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) => { - setIsTimelineHovered(true); - updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [updateTimelineHoverTime], - ); - - const handleTimelineMouseMove = useCallback( - (event: MouseEvent) => { - 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) => { - setIsZoomRowHovered(true); - updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [updateZoomRowHoverTime], - ); - const handleZoomRowMouseMove = useCallback( - (event: MouseEvent) => { - if (!isZoomRowHovered) setIsZoomRowHovered(true); - updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [isZoomRowHovered, updateZoomRowHoverTime], - ); - const handleZoomRowMouseLeave = useCallback(() => { - setIsZoomRowHovered(false); - setZoomRowHoverMs(null); - }, []); - const handleZoomRowClick = useCallback( - (event: MouseEvent) => { - 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, - }; -} diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineAnnotationsActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineAnnotationsActions.ts deleted file mode 100644 index c69f82bb..00000000 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineAnnotationsActions.ts +++ /dev/null @@ -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 }; -} diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts index 55ddc9ac..c49fc338 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts @@ -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( @@ -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, diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 9eb5968f..129cfb33 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -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, diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts index 3c9c45a0..6d8e8a11 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -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(); } }