diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index fa455bde..f16a499f 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -4,7 +4,6 @@ import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, - useImperativeHandle, useMemo, useRef, useState, @@ -31,14 +30,8 @@ import KeyframeMarkers from "./KeyframeMarkers"; import TimelineWrapper from "./TimelineWrapper"; import { useAudioPeaks } from "./useAudioPeaks"; import { calculateTimelineScale } from "./core/time"; -import { useTimelineDndBindings } from "./hooks/useTimelineDndBindings"; -import { useTimelineAnnotationsActions } from "./hooks/useTimelineAnnotationsActions"; -import { useTimelineAudioActions } from "./hooks/useTimelineAudioActions"; -import { useTimelineKeyboardShortcuts } from "./hooks/useTimelineKeyboardShortcuts"; -import { useTimelineNormalization } from "./hooks/useTimelineNormalization"; +import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime"; import { useTimelineRange } from "./hooks/useTimelineRange"; -import { useTimelineSelection } from "./hooks/useTimelineSelection"; -import { useTimelineZoomActions } from "./hooks/useTimelineZoomActions"; import TimelineEditorShell from "./components/editor/TimelineEditorShell"; import TimelineCanvas from "./components/viewport/TimelineCanvas"; import TimelineToolbar from "./components/toolbar/TimelineToolbar"; @@ -60,11 +53,7 @@ export interface TimelineEditorProps { selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; trimRegions?: TrimRegion[]; - onTrimAdded?: (span: Span) => void; onTrimSpanChange?: (id: string, span: Span) => void; - onTrimDelete?: (id: string) => void; - selectedTrimId?: string | null; - onSelectTrim?: (id: string | null) => void; clipRegions?: ClipRegion[]; onClipSplit?: (splitMs: number) => void; onClipSpanChange?: (id: string, span: Span) => void; @@ -78,11 +67,7 @@ export interface TimelineEditorProps { selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; speedRegions?: SpeedRegion[]; - onSpeedAdded?: (span: Span) => void; onSpeedSpanChange?: (id: string, span: Span) => void; - onSpeedDelete?: (id: string) => void; - selectedSpeedId?: string | null; - onSelectSpeed?: (id: string | null) => void; audioRegions?: AudioRegion[]; onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; @@ -126,11 +111,7 @@ const TimelineEditor = forwardRef( selectedZoomId, onSelectZoom, trimRegions = [], - onTrimAdded: _onTrimAdded, onTrimSpanChange, - onTrimDelete: _onTrimDelete, - selectedTrimId: _selectedTrimId, - onSelectTrim: _onSelectTrim, clipRegions = [], onClipSplit, onClipSpanChange, @@ -144,11 +125,7 @@ const TimelineEditor = forwardRef( selectedAnnotationId, onSelectAnnotation, speedRegions = [], - onSpeedAdded: _onSpeedAdded, onSpeedSpanChange, - onSpeedDelete: _onSpeedDelete, - selectedSpeedId: _selectedSpeedId, - onSelectSpeed: _onSelectSpeed, audioRegions = [], onAudioAdded, onAudioSpanChange, @@ -255,168 +232,68 @@ const TimelineEditor = forwardRef( 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, - }); - - // Keep newly added timeline regions at the original short default instead of - // scaling them with the full recording length. - const { - defaultRegionDurationMs, canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, handleSuggestZooms, - } = useTimelineZoomActions({ + handleSplitClip, + handleAddAudio, + handleAddAnnotation, + } = useTimelineEditorRuntime({ + ref, videoDuration, totalMs, currentTimeMs, - zoomRegions, - clipRegions, + safeMinDurationMs, cursorTelemetry, - disableSuggestedZooms, autoSuggestZoomsTrigger, onAutoSuggestZoomsConsumed, + disableSuggestedZooms, + zoomRegions, onZoomAdded, onZoomSuggested, - }); - - const handleSplitClip = useCallback(() => { - if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { - return; - } - onClipSplit(currentTimeMs); - }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); - - const { handleAddAudio } = useTimelineAudioActions({ - videoDuration, - totalMs, - currentTimeMs, + onZoomSpanChange, + onZoomDelete, + selectedZoomId, + onSelectZoom, + trimRegions, + onTrimSpanChange, + clipRegions, + onClipSplit, + onClipSpanChange, + onClipDelete, + selectedClipId, + onSelectClip, + annotationRegions, + onAnnotationAdded, + onAnnotationSpanChange, + onAnnotationDelete, + selectedAnnotationId, + onSelectAnnotation, + speedRegions, + onSpeedSpanChange, audioRegions, onAudioAdded, - }); - - const { handleAddAnnotation } = useTimelineAnnotationsActions({ - videoDuration, - totalMs, - currentTimeMs, - defaultRegionDurationMs, - onAnnotationAdded, - }); - - useTimelineKeyboardShortcuts({ + onAudioSpanChange, + onAudioDelete, + selectedAudioId, + onSelectAudio, 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 ( ) => { + (e: MouseEvent) => { if (!onSeek || videoDurationMs <= 0) return; onSelectZoom?.(null); diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx index 85d37d07..fb1e8b8a 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvasRows.tsx @@ -31,7 +31,7 @@ interface TimelineCanvasRowsProps { onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; audioPeaks?: AudioPeaksData | null; - direction: "ltr" | "rtl"; + direction: string; canShowGhostZoom: boolean; ghostStartMs: number | null; ghostStartOffsetPx: number; diff --git a/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts b/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts index 3069f835..5c9bd40c 100644 --- a/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts +++ b/src/components/video-editor/timeline/components/viewport/useTimelineCanvasHover.ts @@ -1,7 +1,7 @@ import { useCallback, useState, type MouseEvent } from "react"; interface UseTimelineCanvasHoverParams { - direction: "ltr" | "rtl"; + direction: string; sidebarWidth: number; rangeStart: number; rangeEnd: number; diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts new file mode 100644 index 00000000..d4311eab --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -0,0 +1,291 @@ +import type { Span } from "dnd-timeline"; +import { useCallback, useImperativeHandle } from "react"; +import type { ForwardedRef, RefObject } from "react"; +import type { ShortcutBinding } from "@/lib/shortcuts"; +import { useTimelineDndBindings } from "./useTimelineDndBindings"; +import { useTimelineAnnotationsActions } from "./useTimelineAnnotationsActions"; +import { useTimelineAudioActions } from "./useTimelineAudioActions"; +import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; +import { useTimelineNormalization } from "./useTimelineNormalization"; +import { useTimelineSelection } from "./useTimelineSelection"; +import { useTimelineZoomActions } from "./useTimelineZoomActions"; +import type { + AnnotationRegion, + AudioRegion, + ClipRegion, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + ZoomFocus, + ZoomRegion, +} from "../../types"; +import type { TimelineEditorHandle } from "../TimelineEditor"; + +interface UseTimelineEditorRuntimeParams { + ref: ForwardedRef; + 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: { + addKeyframe: ShortcutBinding; + addZoom: ShortcutBinding; + splitClip: ShortcutBinding; + addAnnotation: ShortcutBinding; + deleteSelected: ShortcutBinding; + }; + isTimelineFocusedRef: RefObject; +} + +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({ + videoDuration, + totalMs, + currentTimeMs, + zoomRegions, + clipRegions, + cursorTelemetry, + 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({ + videoDuration, + totalMs, + currentTimeMs, + audioRegions, + onAudioAdded, + }); + + const { handleAddAnnotation } = useTimelineAnnotationsActions({ + 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, + }; +}