diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 9b024082..39f46b26 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,16 +1,7 @@ import { - Check, - CaretDown as ChevronDown, - Crop, - ChatText as MessageSquare, - MusicNote as Music, Plus, - Scissors, - MagicWand as WandSparkles, - MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; -import { useTimelineContext } from "dnd-timeline"; import { forwardRef, type KeyboardEvent as ReactKeyboardEvent, @@ -22,21 +13,10 @@ import { useState, } from "react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { cn } from "@/lib/utils"; import { - ASPECT_RATIOS, type AspectRatio, - getAspectRatioLabel, - isCustomAspectRatio, } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences"; @@ -50,23 +30,17 @@ import type { ZoomFocus, ZoomRegion, } from "../types"; -import AudioWaveform from "./AudioWaveform"; -import Item from "./Item"; -import glassStyles from "./ItemGlass.module.css"; import KeyframeMarkers from "./KeyframeMarkers"; -import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; +import { useAudioPeaks } from "./useAudioPeaks"; import { - getTimelineContentMinHeightPx, - getTimelineRowsMinHeightPx, - getTimelineViewportStretchFactor, - TIMELINE_AXIS_HEIGHT_PX, -} from "./timelineLayout"; -import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks"; -import { CLIP_ROW_ID, ZOOM_ROW_ID } from "./core/constants"; -import { getAnnotationTrackIndex, getAnnotationTrackRowId, getAudioTrackIndex, getAudioTrackRowId, isAnnotationTrackRowId, isAudioTrackRowId } from "./core/rows"; + getAnnotationTrackIndex, + getAudioTrackIndex, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "./core/rows"; import { spansOverlap } from "./core/spans"; -import { calculateAxisScale, calculateTimelineScale, formatPlayheadTime, formatTimeLabel } from "./core/time"; +import { calculateTimelineScale } from "./core/time"; import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "./model/timelineModel"; import { useTimelineAnnotationsActions } from "./hooks/useTimelineAnnotationsActions"; import { useTimelineAudioActions } from "./hooks/useTimelineAudioActions"; @@ -75,6 +49,8 @@ import { useTimelineNormalization } from "./hooks/useTimelineNormalization"; import { useTimelineRange } from "./hooks/useTimelineRange"; import { useTimelineSelection } from "./hooks/useTimelineSelection"; import { useTimelineZoomActions } from "./hooks/useTimelineZoomActions"; +import TimelineCanvas from "./components/viewport/TimelineCanvas"; +import TimelineToolbar from "./components/toolbar/TimelineToolbar"; export interface TimelineEditorProps { videoDuration: number; @@ -140,731 +116,6 @@ export interface TimelineEditorHandle { } -function PlaybackCursor({ - currentTimeMs, - videoDurationMs, - onSeek, - timelineRef, - keyframes = [], -}: { - currentTimeMs: number; - videoDurationMs: number; - onSeek?: (time: number) => void; - timelineRef: React.RefObject; - keyframes?: { id: string; time: number }[]; -}) { - 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; - - // Allow dragging outside to 0 or max, but clamp the value - const relativeMs = pixelsToValue(clickX); - let absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - - // Snap to nearby keyframe if within threshold (150ms) - 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 ( -
-
{ - e.stopPropagation(); // Prevent timeline click - setIsDragging(true); - }} - > -
-
-
-
- {formatPlayheadTime(clampedTime)} -
-
-
- ); -} - -function TimelineAxis({ - videoDurationMs, - currentTimeMs, -}: { - videoDurationMs: number; - currentTimeMs: number; -}) { - 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: [] }; - } - - 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(); - - const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs; - - for (let time = firstMarker; time <= maxTime; time += intervalMs) { - if (time >= visibleStart && time <= visibleEnd) { - 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); - - // Generate minor ticks (4 ticks between major intervals) - const minorTicks = []; - const minorInterval = intervalMs / 5; - - for (let time = firstMarker; time <= maxTime; time += minorInterval) { - if (time >= visibleStart && time <= visibleEnd) { - // Skip if it's close to a major marker - 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 ( -
- {/* Minor Ticks */} - {markers.minorTicks.map((time) => { - const offset = valueToPixels(time - range.start); - return ( -
- ); - })} - - {/* Major Markers */} - {markers.markers.map((marker) => { - const offset = valueToPixels(marker.time - range.start); - const markerStyle: React.CSSProperties = { - position: "absolute", - bottom: 0, - height: "100%", - display: "flex", - flexDirection: "row", - alignItems: "flex-end", - [sideProperty]: `${offset}px`, - transform: "translateX(-50%)", - }; - - return ( -
-
-
- - {marker.label} - -
-
- ); - })} -
- ); -} - -function ClipMarkerOverlay({ videoDurationMs }: { videoDurationMs: number }) { - 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 []; - 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 ( -
- {markers.map(({ time, offset }) => ( -
- ))} -
- ); -} - -function Timeline({ - items, - videoDurationMs, - currentTimeMs, - onSeek, - onAddZoomAtMs, - canPlaceZoomAtMs, - onSelectZoom, - onSelectTrim, - onSelectClip, - onSelectAnnotation, - onSelectSpeed, - onSelectAudio, - selectedZoomId, - selectedTrimId: _selectedTrimId, - selectedClipId, - selectedAnnotationId, - selectedSpeedId: _selectedSpeedId, - selectedAudioId, - selectAllBlocksActive = false, - onClearBlockSelection, - keyframes = [], - audioPeaks, -}: { - items: TimelineRenderItem[]; - videoDurationMs: number; - currentTimeMs: number; - onSeek?: (time: number) => void; - canPlaceZoomAtMs?: (startMs: number) => boolean; - onSelectZoom?: (id: string | null) => void; - onSelectTrim?: (id: string | null) => void; - onSelectClip?: (id: string | null) => void; - onSelectAnnotation?: (id: string | null) => void; - onSelectSpeed?: (id: string | null) => void; - onSelectAudio?: (id: string | null) => void; - onAddZoomAtMs?: (startMs: number) => void; - selectedZoomId: string | null; - selectedTrimId?: string | null; - selectedClipId?: string | null; - selectedAnnotationId?: string | null; - selectedSpeedId?: string | null; - selectedAudioId?: string | null; - selectAllBlocksActive?: boolean; - onClearBlockSelection?: () => void; - keyframes?: { id: string; time: number }[]; - audioPeaks?: AudioPeaksData | null; -}) { - const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = - useTimelineContext(); - const localTimelineRef = useRef(null); - const [isTimelineHovered, setIsTimelineHovered] = useState(false); - const [timelineHoverMs, setTimelineHoverMs] = useState(null); - const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); - const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); - - const setRefs = useCallback( - (node: HTMLDivElement | null) => { - setTimelineRef(node); - localTimelineRef.current = node; - }, - [setTimelineRef], - ); - - const handleTimelineClick = useCallback( - (e: React.MouseEvent) => { - if (!onSeek || videoDurationMs <= 0) return; - - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectClip?.(null); - onSelectAnnotation?.(null); - onSelectSpeed?.(null); - onSelectAudio?.(null); - onClearBlockSelection?.(); - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; - - if (clickX < 0) return; - - const relativeMs = pixelsToValue(clickX); - const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - - onSeek(timeInSeconds); - }, - [ - onSeek, - onSelectZoom, - onSelectTrim, - onSelectClip, - onSelectAnnotation, - onSelectSpeed, - onSelectAudio, - onClearBlockSelection, - videoDurationMs, - sidebarWidth, - range.start, - pixelsToValue, - ], - ); - - const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); - const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID); - const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId)); - const audioItems = items.filter((item) => isAudioTrackRowId(item.rowId)); - const audioRowIds = useMemo( - () => - Array.from( - new Set( - audioItems.map((item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId))), - ), - ).sort((left, right) => getAudioTrackIndex(left) - getAudioTrackIndex(right)), - [audioItems], - ); - const annotationRowIds = useMemo( - () => - Array.from( - new Set( - annotationItems.map((item) => - getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)), - ), - ), - ).sort((left, right) => getAnnotationTrackIndex(left) - getAnnotationTrackIndex(right)), - [annotationItems], - ); - const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length; - const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); - const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); - const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); - const sideProperty = direction === "rtl" ? "right" : "left"; - const visibleDurationMs = Math.max(1, range.end - range.start); - 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 - range.start)); - const ghostEndOffsetPx = - ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start)); - const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); - const timelineGhostOffsetPx = - timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start)); - const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null; - const canShowGhostZoom = - isZoomRowHovered && - ghostStartMs !== null && - (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false); - - 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 = range.start + ratio * visibleDurationMs; - setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); - }, - [direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs], - ); - - const handleTimelineMouseEnter = useCallback( - (event: React.MouseEvent) => { - setIsTimelineHovered(true); - updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [updateTimelineHoverTime], - ); - - const handleTimelineMouseMove = useCallback( - (event: React.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 = range.start + ratio * visibleDurationMs; - setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); - }, - [direction, range.start, videoDurationMs, visibleDurationMs], - ); - - const handleZoomRowMouseEnter = useCallback( - (event: React.MouseEvent) => { - setIsZoomRowHovered(true); - updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [updateZoomRowHoverTime], - ); - - const handleZoomRowMouseMove = useCallback( - (event: React.MouseEvent) => { - if (!isZoomRowHovered) { - setIsZoomRowHovered(true); - } - updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); - }, - [isZoomRowHovered, updateZoomRowHoverTime], - ); - - const handleZoomRowMouseLeave = useCallback(() => { - setIsZoomRowHovered(false); - setZoomRowHoverMs(null); - }, []); - - const handleZoomRowClick = useCallback( - (event: React.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], - ); - - return ( -
- - - {canShowGhostPlayhead && ( -
-
-
- )} - -
- - {audioPeaks && } - - {clipItems.map((item) => ( - onSelectClip?.(item.id)} - variant="clip" - > - {item.label} - - ))} - - - - {canShowGhostZoom && ghostStartMs !== null && ( -
-
-
-
-
-
- -
-
-
-
- )} - {zoomItems.map((item) => ( - onSelectZoom?.(item.id)} - zoomDepth={item.zoomDepth} - zoomMode={item.zoomMode} - variant="zoom" - > - {item.label} - - ))} - - - {annotationRowIds.map((rowId, index) => { - const rowItems = annotationItems.filter( - (item) => - getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)) === rowId, - ); - - return ( - - {rowItems.map((item) => ( - onSelectAnnotation?.(item.id)} - variant="annotation" - > - {item.label} - - ))} - - ); - })} - - {audioRowIds.map((rowId, index) => { - const rowItems = audioItems.filter( - (item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)) === rowId, - ); - - return ( - - {rowItems.map((item) => ( - onSelectAudio?.(item.id)} - variant="audio" - > - {item.label} - - ))} - - ); - })} -
-
- ); -} - const TimelineEditor = forwardRef( function TimelineEditor( { @@ -1308,166 +559,27 @@ const TimelineEditor = forwardRef( return (
{hideToolbar ? null : ( -
-
- - - - - -
-
- - - - - - {ASPECT_RATIOS.map((ratio) => ( - onAspectRatioChange?.(ratio)} - className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3" - > - {getAspectRatioLabel(ratio)} - {aspectRatio === ratio && ( - - )} - - ))} -
-
- Custom - - setCustomAspectWidth( - event.target.value.replace(/\D/g, ""), - ) - } - onKeyDown={handleCustomAspectRatioKeyDown} - 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" - /> - : - - setCustomAspectHeight( - event.target.value.replace(/\D/g, ""), - ) - } - onKeyDown={handleCustomAspectRatioKeyDown} - 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" - /> - - {isCustomAspectRatio(aspectRatio) && ( - - )} -
- - -
- -
-
-
- - - Side Scroll - - Pan - - - - {scrollLabels.pan} - - Pan - - - - {scrollLabels.zoom} - - Zoom - -
-
+ handleAddAnnotation()} + onAddAudio={() => { + void handleAddAudio(); + }} + onSplitClip={handleSplitClip} + cropLabel={t("sections.crop", "Crop")} + /> )}
( videoDurationMs={totalMs} timelineRef={timelineContainerRef} /> - 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(); + const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs; + + for (let time = firstMarker; time <= maxTime; time += intervalMs) { + if (time >= visibleStart && time <= visibleEnd) 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 <= maxTime; time += minorInterval) { + if (time >= visibleStart && time <= visibleEnd) { + 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 ( +
+ {markers.minorTicks.map((time) => { + const offset = valueToPixels(time - range.start); + return ( +
+ ); + })} + + {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: "translateX(-50%)", + }; + + return ( +
+
+
+ + {marker.label} + +
+
+ ); + })} +
+ ); +} diff --git a/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx b/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx new file mode 100644 index 00000000..4dd66c8d --- /dev/null +++ b/src/components/video-editor/timeline/components/overlays/ClipMarkerOverlay.tsx @@ -0,0 +1,53 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useMemo } from "react"; +import { calculateAxisScale } from "../../core/time"; + +interface ClipMarkerOverlayProps { + videoDurationMs: number; +} + +export default function ClipMarkerOverlay({ 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 ( +
+ {markers.map(({ time, offset }) => ( +
+ ))} +
+ ); +} diff --git a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx new file mode 100644 index 00000000..7ff0c1a9 --- /dev/null +++ b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx @@ -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; + 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 ( +
+
{ + e.stopPropagation(); + setIsDragging(true); + }} + > +
+
+
+
+ {formatPlayheadTime(clampedTime)} +
+
+
+ ); +} diff --git a/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx new file mode 100644 index 00000000..1c947e90 --- /dev/null +++ b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx @@ -0,0 +1,134 @@ +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) => void; + onApplyCustomAspectRatio: () => void; + onAspectRatioChange?: (aspectRatio: AspectRatio) => void; + onOpenCropEditor?: () => void; + onAddZoom: () => void; + onSuggestZooms: () => void; + onAddAnnotation: () => void; + onAddAudio: () => void; + onSplitClip: () => void; + cropLabel: string; +} + +export default function TimelineToolbar({ + aspectRatio, + isCropped, + scrollLabels, + customAspectWidth, + customAspectHeight, + onCustomAspectWidthChange, + onCustomAspectHeightChange, + onCustomAspectRatioKeyDown, + onApplyCustomAspectRatio, + onAspectRatioChange, + onOpenCropEditor, + onAddZoom, + onSuggestZooms, + onAddAnnotation, + onAddAudio, + onSplitClip, + cropLabel, +}: TimelineToolbarProps) { + return ( +
+
+ + + + + +
+
+ + + + + + {ASPECT_RATIOS.map((ratio) => ( + onAspectRatioChange?.(ratio)} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3"> + {getAspectRatioLabel(ratio)} + {aspectRatio === ratio && } + + ))} +
+
+ Custom + 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" /> + : + 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" /> + + {isCustomAspectRatio(aspectRatio) && } +
+ + +
+ +
+
+
+ + Side Scroll + Pan + + + {scrollLabels.pan} + Pan + + + {scrollLabels.zoom} + Zoom + +
+
+ ); +} diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx new file mode 100644 index 00000000..1bcceb27 --- /dev/null +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -0,0 +1,398 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Plus } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import glassStyles from "../../ItemGlass.module.css"; +import Item from "../../Item"; +import Row from "../../Row"; +import { + getTimelineContentMinHeightPx, + getTimelineRowsMinHeightPx, + getTimelineViewportStretchFactor, + TIMELINE_AXIS_HEIGHT_PX, +} from "../../timelineLayout"; +import type { AudioPeaksData } from "../../useAudioPeaks"; +import AudioWaveform from "../../AudioWaveform"; +import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants"; +import { + getAnnotationTrackIndex, + getAnnotationTrackRowId, + getAudioTrackIndex, + getAudioTrackRowId, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../../core/rows"; +import type { TimelineRenderItem } from "../../model/timelineModel"; +import TimelineAxis from "../axis/TimelineAxis"; +import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay"; +import PlaybackCursor from "../playhead/PlaybackCursor"; + +interface TimelineCanvasProps { + items: TimelineRenderItem[]; + videoDurationMs: number; + currentTimeMs: number; + onSeek?: (time: number) => void; + canPlaceZoomAtMs?: (startMs: number) => boolean; + onSelectZoom?: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; + onSelectClip?: (id: string | null) => void; + onSelectAnnotation?: (id: string | null) => void; + onSelectSpeed?: (id: string | null) => void; + onSelectAudio?: (id: string | null) => void; + onAddZoomAtMs?: (startMs: number) => void; + selectedZoomId: string | null; + selectedTrimId?: string | null; + selectedClipId?: string | null; + selectedAnnotationId?: string | null; + selectedSpeedId?: string | null; + selectedAudioId?: string | null; + selectAllBlocksActive?: boolean; + onClearBlockSelection?: () => void; + keyframes?: { id: string; time: number }[]; + audioPeaks?: AudioPeaksData | null; +} + +export default function TimelineCanvas({ + items, + videoDurationMs, + currentTimeMs, + onSeek, + onAddZoomAtMs, + canPlaceZoomAtMs, + onSelectZoom, + onSelectTrim, + onSelectClip, + onSelectAnnotation, + onSelectSpeed, + onSelectAudio, + selectedZoomId, + selectedTrimId: _selectedTrimId, + selectedClipId, + selectedAnnotationId, + selectedSpeedId: _selectedSpeedId, + selectedAudioId, + selectAllBlocksActive = false, + onClearBlockSelection, + keyframes = [], + audioPeaks, +}: TimelineCanvasProps) { + const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = + useTimelineContext(); + const localTimelineRef = useRef(null); + const [isTimelineHovered, setIsTimelineHovered] = useState(false); + const [timelineHoverMs, setTimelineHoverMs] = useState(null); + const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); + const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + setTimelineRef(node); + localTimelineRef.current = node; + }, + [setTimelineRef], + ); + + const handleTimelineClick = useCallback( + (e: React.MouseEvent) => { + if (!onSeek || videoDurationMs <= 0) return; + + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onClearBlockSelection?.(); + + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = 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); + }, + [ + onSeek, + onSelectZoom, + onSelectTrim, + onSelectClip, + onSelectAnnotation, + onSelectSpeed, + onSelectAudio, + onClearBlockSelection, + videoDurationMs, + sidebarWidth, + range.start, + pixelsToValue, + ], + ); + + const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); + const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID); + const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId)); + const audioItems = items.filter((item) => isAudioTrackRowId(item.rowId)); + const audioRowIds = useMemo( + () => + Array.from(new Set(audioItems.map((item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId))))).sort( + (left, right) => getAudioTrackIndex(left) - getAudioTrackIndex(right), + ), + [audioItems], + ); + const annotationRowIds = useMemo( + () => + Array.from( + new Set(annotationItems.map((item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)))), + ).sort((left, right) => getAnnotationTrackIndex(left) - getAnnotationTrackIndex(right)), + [annotationItems], + ); + + const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length; + const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); + const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); + const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); + const sideProperty = direction === "rtl" ? "right" : "left"; + const visibleDurationMs = Math.max(1, range.end - range.start); + + 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 - range.start)); + const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start)); + const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); + const timelineGhostOffsetPx = timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start)); + const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null; + const canShowGhostZoom = + isZoomRowHovered && ghostStartMs !== null && (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false); + + 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 = range.start + ratio * visibleDurationMs; + setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs], + ); + + const handleTimelineMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateTimelineHoverTime], + ); + + const handleTimelineMouseMove = useCallback( + (event: React.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 = range.start + ratio * visibleDurationMs; + setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, videoDurationMs, visibleDurationMs], + ); + + const handleZoomRowMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateZoomRowHoverTime], + ); + const handleZoomRowMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!isZoomRowHovered) setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isZoomRowHovered, updateZoomRowHoverTime], + ); + const handleZoomRowMouseLeave = useCallback(() => { + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + const handleZoomRowClick = useCallback( + (event: React.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], + ); + + return ( +
+ + + {canShowGhostPlayhead && ( +
+
+
+ )} + +
+ + {audioPeaks && } + + {clipItems.map((item) => ( + onSelectClip?.(item.id)} + variant="clip" + > + {item.label} + + ))} + + + + {canShowGhostZoom && ghostStartMs !== null && ( +
+
+
+
+
+
+ +
+
+
+
+ )} + {zoomItems.map((item) => ( + onSelectZoom?.(item.id)} + zoomDepth={item.zoomDepth} + zoomMode={item.zoomMode} + variant="zoom" + > + {item.label} + + ))} + + + {annotationRowIds.map((rowId, index) => { + const rowItems = annotationItems.filter( + (item) => getAnnotationTrackRowId(getAnnotationTrackIndex(item.rowId)) === rowId, + ); + return ( + + {rowItems.map((item) => ( + onSelectAnnotation?.(item.id)} + variant="annotation" + > + {item.label} + + ))} + + ); + })} + + {audioRowIds.map((rowId, index) => { + const rowItems = audioItems.filter( + (item) => getAudioTrackRowId(getAudioTrackIndex(item.rowId)) === rowId, + ); + return ( + + {rowItems.map((item) => ( + onSelectAudio?.(item.id)} + variant="audio" + > + {item.label} + + ))} + + ); + })} +
+
+ ); +}