diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-02-10 16:28:21 +0100 |
| commit | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (patch) | |
| tree | 11500ff0fcc4a933650e3e91f1ea00fd315c4d0c /apps/web/src/components/editor/panels/timeline | |
| parent | 33a8705f0d967891402c61dd3192e6bad24921ef (diff) | |
| download | opencut-fca99d6126c31fbb18ed9f1034cee6f940b040e8.tar.gz | |
refactor
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
11 files changed, 2122 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx new file mode 100644 index 00000000..bbc5cb83 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; + +interface AudioWaveformProps { + audioUrl?: string; + audioBuffer?: AudioBuffer; + height?: number; + className?: string; +} + +function extractPeaks({ + buffer, + length = 512, +}: { + buffer: AudioBuffer; + length?: number; +}): number[][] { + const channels = buffer.numberOfChannels; + const peaks: number[][] = []; + + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c); + const step = Math.floor(data.length / length); + const channelPeaks: number[] = []; + + for (let i = 0; i < length; i++) { + const start = i * step; + const end = Math.min(start + step, data.length); + let max = 0; + for (let j = start; j < end; j++) { + const abs = Math.abs(data[j]); + if (abs > max) max = abs; + } + channelPeaks.push(max); + } + peaks.push(channelPeaks); + } + + return peaks; +} + +export function AudioWaveform({ + audioUrl, + audioBuffer, + height = 32, + className = "", +}: AudioWaveformProps) { + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let mounted = true; + const ws = wavesurfer.current; + + const initWaveSurfer = async () => { + if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + + try { + if (ws) { + wavesurfer.current = null; + } + + const newWaveSurfer = WaveSurfer.create({ + container: waveformRef.current, + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", + barWidth: 2, + barGap: 1, + height, + normalize: true, + interact: false, + }); + + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + try { + newWaveSurfer.destroy(); + } catch {} + return; + } + + newWaveSurfer.on("ready", () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); + + newWaveSurfer.on("error", (err) => { + if (mounted) { + console.error("WaveSurfer error:", err); + setError(true); + setIsLoading(false); + } + }); + + if (audioBuffer) { + const peaks = extractPeaks({ buffer: audioBuffer }); + newWaveSurfer.load("", peaks, audioBuffer.duration); + } else if (audioUrl) { + await newWaveSurfer.load(audioUrl); + } + } catch (err) { + if (mounted) { + console.error("Failed to initialize WaveSurfer:", err); + setError(true); + setIsLoading(false); + } + } + }; + + if (ws) { + const wsToDestroy = ws; + wavesurfer.current = null; + + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + if (mounted) { + initWaveSurfer(); + } + }); + } else { + initWaveSurfer(); + } + + return () => { + mounted = false; + + const wsToDestroy = wavesurfer.current; + + wavesurfer.current = null; + + if (wsToDestroy) { + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + }); + } + }; + }, [audioUrl, audioBuffer, height]); + + if (error) { + return ( + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > + <span className="text-foreground/60 text-xs">Audio unavailable</span> + </div> + ); + } + + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-foreground/60 text-xs">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`} + style={{ height }} + /> + </div> + ); +} + +export default AudioWaveform; diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx new file mode 100644 index 00000000..cfcb1e23 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -0,0 +1,111 @@ +import { useEditor } from "@/hooks/use-editor"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getSnappedSeekTime } from "@/lib/time"; +import { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +interface TimelineBookmarksRowProps { + zoomLevel: number; + dynamicTimelineWidth: number; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineBookmarksRow({ + zoomLevel, + dynamicTimelineWidth, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineBookmarksRowProps) { + const editor = useEditor(); + const activeScene = editor.scenes.getActiveScene(); + + return ( + <div className="relative h-4 flex-1 overflow-hidden"> + <button + className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + aria-label="Timeline ruler" + type="button" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={(event) => { + handleRulerMouseDown(event); + handleRulerTrackingMouseDown(event); + }} + > + {activeScene.bookmarks.map((time: number) => ( + <TimelineBookmark + key={`bookmark-row-${time}`} + time={time} + zoomLevel={zoomLevel} + /> + ))} + </button> + </div> + ); +} + +export function TimelineBookmark({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + + const handleBookmarkActivate = ({ + event, + }: { + event: + | React.MouseEvent<HTMLButtonElement> + | React.KeyboardEvent<HTMLButtonElement>; + }) => { + event.stopPropagation(); + const framesPerSecond = activeProject?.settings.fps ?? 30; + const snappedTime = getSnappedSeekTime({ + rawTime: time, + duration, + fps: framesPerSecond, + }); + editor.playback.seek({ time: snappedTime }); + }; + + return ( + <button + className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + aria-label={`Seek to bookmark at ${time}s`} + type="button" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => handleBookmarkActivate({ event })} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleBookmarkActivate({ event }); + }} + > + <div className="text-primary absolute top-[-1px] left-[-5px]"> + <HugeiconsIcon + icon={Bookmark02Icon} + aria-hidden="true" + className="fill-primary size-3" + /> + </div> + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx new file mode 100644 index 00000000..f31c6e28 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -0,0 +1,28 @@ +import { getDropLineY } from "@/lib/timeline/drop-utils"; +import type { TimelineTrack, DropTarget } from "@/types/timeline"; + +interface DragLineProps { + dropTarget: DropTarget | null; + tracks: TimelineTrack[]; + isVisible: boolean; + headerHeight?: number; +} + +export function DragLine({ + dropTarget, + tracks, + isVisible, + headerHeight = 0, +}: DragLineProps) { + if (!isVisible || !dropTarget) return null; + + const y = getDropLineY({ dropTarget, tracks }); + const lineTop = y + headerHeight; + + return ( + <div + className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + style={{ top: `${lineTop}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx new file mode 100644 index 00000000..94edc2d0 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Delete02Icon, + TaskAdd02Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "../../../ui/context-menu"; +import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; +import { useState, useRef, useCallback } from "react"; +import { TimelineTrackContent } from "./timeline-track"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SelectionBox } from "../../selection-box"; +import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; +import { + TIMELINE_CONSTANTS, + TRACK_ICONS, +} from "@/constants/timeline-constants"; +import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + canTracktHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, + isMainTrack, +} from "@/lib/timeline"; +import { TimelineToolbar } from "./timeline-toolbar"; +import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; +import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { TimelineBookmarksRow } from "./bookmarks"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useEditor } from "@/hooks/use-editor"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/lib/actions"; + +export function Timeline() { + const tracksContainerHeight = { min: 0, max: 800 }; + const { snappingEnabled } = useTimelineStore(); + const { clearElementSelection, setElementSelection } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const tracks = timeline.getTracks(); + const seek = (time: number) => editor.playback.seek({ time }); + + // refs + const timelineRef = useRef<HTMLDivElement>(null); + const timelineHeaderRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const tracksContainerRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + + // state + const [isResizing, setIsResizing] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + const handleResizeStateChange = useCallback( + ({ isResizing: nextIsResizing }: { isResizing: boolean }) => { + setIsResizing(nextIsResizing); + if (!nextIsResizing) { + setCurrentSnapPoint(null); + } + }, + [], + ); + + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); + + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef: tracksScrollRef, + }); + + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef: timelineHeaderRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useSelectionBox({ + containerRef: tracksContainerRef, + onSelectionComplete: (elements) => { + setElementSelection({ elements }); + }, + tracksScrollRef, + zoomLevel, + }); + + const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const contentWidth = + timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + contentWidth + paddingPx, + containerWidth, + ); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + useScrollSync({ + tracksScrollRef, + trackLabelsScrollRef, + }); + + const timelineHeaderHeight = + timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + + return ( + <section + className={ + "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border" + } + {...dragProps} + aria-label="Timeline" + > + <TimelineToolbar + zoomLevel={zoomLevel} + minZoom={minZoomLevel} + setZoomLevel={({ zoom }) => setZoomLevel(zoom)} + /> + + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={timelineRef} + > + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + /> + <div className="flex flex-1 overflow-hidden"> + <div className="bg-background flex w-28 shrink-0 flex-col border-r"> + <div className="bg-background flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + <div className="bg-background flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="bg-background flex-1 overflow-y-auto" + style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} + > + <ScrollArea className="size-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="group flex items-center px-3" + style={{ + height: `${getTrackHeight({ type: track.type })}px`, + }} + > + <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( + <div className="bg-red-500 size-1.5 rounded-full" /> + )} + {canTracktHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ + on: VolumeHighIcon, + off: VolumeOffIcon, + }} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ + on: ViewIcon, + off: ViewOffSlashIcon, + }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + </div> + + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={timeline.getTracks()} + isVisible={isDragOver} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={timeline.getTracks()} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + <ScrollArea + className="size-full overflow-y-hidden" + ref={tracksScrollRef} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); + }} + onWheel={(event) => { + if ( + event.shiftKey || + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ) { + return; + } + handleWheel(event); + }} + onScroll={() => { + saveScrollPosition(); + }} + > + <div + className="relative" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + > + <div + ref={timelineHeaderRef} + className="bg-background sticky top-0 z-30 flex flex-col" + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + tracksScrollRef={tracksScrollRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + </div> + <TimelinePlayhead + zoomLevel={zoomLevel} + rulerRef={rulerRef} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <div + className="relative" + style={{ + height: `${Math.max( + tracksContainerHeight.min, + Math.min( + tracksContainerHeight.max, + getTotalTracksHeight({ tracks }), + ), + )}px`, + }} + > + {tracks.length === 0 ? ( + <div /> + ) : ( + tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute right-0 left-0" + style={{ + top: `${getCumulativeHeightBefore({ + tracks, + trackIndex: index, + })}px`, + height: `${getTrackHeight({ + type: track.type, + })}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={handleSnapPointChange} + onResizeStateChange={handleResizeStateChange} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackClick={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={VolumeHighIcon} /> + <span> + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={ViewIcon} /> + <span> + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + <HugeiconsIcon icon={Delete02Icon} /> + Delete track + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + )) + )} + </div> + </div> + </ScrollArea> + </div> + </div> + </div> + </section> + ); +} + +function TrackIcon({ track }: { track: TimelineTrack }) { + return <>{TRACK_ICONS[track.type]}</>; +} + +function TrackToggleIcon({ + isOff, + icons, + onClick, +}: { + isOff: boolean; + icons: { + on: IconSvgElement; + off: IconSvgElement; + }; + onClick: () => void; +}) { + return ( + <> + {isOff ? ( + <HugeiconsIcon + icon={icons.off} + className="text-destructive size-4 cursor-pointer" + onClick={onClick} + /> + ) : ( + <HugeiconsIcon + icon={icons.on} + className="text-muted-foreground size-4 cursor-pointer" + onClick={onClick} + /> + )} + </> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx new file mode 100644 index 00000000..914edccb --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; + +interface SnapIndicatorProps { + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement | null>; + trackLabelsRef?: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, +}: SnapIndicatorProps) { + const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, + }); + + if (!isVisible || !snapPoint) { + return null; + } + + return ( + <div + className="pointer-events-none absolute z-90" + style={{ + left: `${leftPosition}px`, + top: topPosition, + height: `${height}px`, + width: "2px", + }} + > + <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx new file mode 100644 index 00000000..59ce88da --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -0,0 +1,566 @@ +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import AudioWaveform from "./audio-waveform"; +import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + getTrackClasses, + getTrackHeight, + canElementHaveAudio, + canElementBeHidden, + hasMediaId, +} from "@/lib/timeline"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../../../ui/context-menu"; +import type { + TimelineElement as TimelineElementType, + TimelineTrack, + ElementDragState, +} from "@/types/timeline"; +import type { MediaAsset } from "@/types/assets"; +import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import Image from "next/image"; +import { + ScissorIcon, + Delete02Icon, + Copy01Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, + VolumeMute02Icon, + Search01Icon, + Exchange01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { uppercase } from "@/utils/string"; +import type { ComponentProps } from "react"; + +function getDisplayShortcut(action: TAction) { + const { defaultShortcuts } = getActionDefinition(action); + if (!defaultShortcuts?.length) { + return ""; + } + + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} + +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + dragState: ElementDragState; +} + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + dragState, +}: TimelineElementProps) { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + const { requestRevealMedia } = useAssetsPanelStore(); + + const mediaAssets = editor.media.getAssets(); + let mediaAsset: MediaAsset | null = null; + + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } + + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); + + const { handleResizeStart, isResizing, currentStartTime, currentDuration } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }); + + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); + + const isBeingDragged = dragState.elementId === element.id; + const dragOffsetY = + isBeingDragged && dragState.isDragging + ? dragState.currentMouseY - dragState.startMouseY + : 0; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + const displayedStartTime = isResizing ? currentStartTime : elementStartTime; + const displayedDuration = isResizing ? currentDuration : element.duration; + const elementWidth = + displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const elementLeft = displayedStartTime * 50 * zoomLevel; + + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; + + const isMuted = canElementHaveAudio(element) && element.muted === true; + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none ${ + isBeingDragged ? "z-30" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + <ElementInner + element={element} + track={track} + isSelected={isSelected} + isBeingDragged={isBeingDragged} + hasAudio={hasAudio} + isMuted={isMuted} + mediaAssets={mediaAssets} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + handleResizeStart={handleResizeStart} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-64"> + <ActionMenuItem + action="split" + icon={<HugeiconsIcon icon={ScissorIcon} />} + > + Split + </ActionMenuItem> + <CopyMenuItem /> + {canElementHaveAudio(element) && hasAudio && ( + <MuteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canElementBeHidden(element) && ( + <VisibilityMenuItem + element={element} + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {selectedElements.length === 1 && ( + <ActionMenuItem + action="duplicate-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Duplicate + </ActionMenuItem> + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + <ContextMenuItem + icon={<HugeiconsIcon icon={Search01Icon} />} + onClick={(event) => handleRevealInMedia({ event })} + > + Reveal media + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={Exchange01Icon} />} + disabled + > + Replace media + </ContextMenuItem> + </> + )} + <ContextMenuSeparator /> + <DeleteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + </ContextMenuContent> + </ContextMenu> + ); +} + +function ElementInner({ + element, + track, + isSelected, + isBeingDragged, + hasAudio, + isMuted, + mediaAssets, + onElementClick, + onElementMouseDown, + handleResizeStart, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + isBeingDragged: boolean; + hasAudio: boolean; + isMuted: boolean; + mediaAssets: MediaAsset[]; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + return ( + <div + className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( + { + type: track.type, + }, + )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + > + <button + type="button" + className="absolute inset-0 size-full cursor-pointer" + onClick={(e) => onElementClick(e, element)} + onMouseDown={(e) => onElementMouseDown(e, element)} + > + <div className="absolute inset-0 flex h-full items-center"> + <ElementContent + element={element} + track={track} + isSelected={isSelected} + mediaAssets={mediaAssets} + /> + </div> + + {(hasAudio + ? isMuted + : canElementBeHidden(element) && element.hidden) && ( + <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> + {hasAudio ? ( + <HugeiconsIcon + icon={VolumeHighIcon} + className="size-6 text-white" + /> + ) : ( + <HugeiconsIcon + icon={VolumeOffIcon} + className="size-6 text-white" + /> + )} + </div> + )} + </button> + + {isSelected && ( + <> + <ResizeHandle + side="left" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + <ResizeHandle + side="right" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + </> + )} + </div> + ); +} + +function ResizeHandle({ + side, + elementId, + handleResizeStart, +}: { + side: "left" | "right"; + elementId: string; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + const isLeft = side === "left"; + return ( + <button + type="button" + className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + aria-label={`${isLeft ? "Left" : "Right"} resize handle`} + > + <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> + </button> + ); +} + +function ElementContent({ + element, + track, + isSelected, + mediaAssets, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + mediaAssets: MediaAsset[]; +}) { + if (element.type === "text") { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); + } + + if (element.type === "sticker") { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} + alt={element.name} + className="size-5 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); + } + + if (element.type === "audio") { + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + + const audioUrl = + element.sourceType === "library" + ? element.sourceUrl + : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; + + if (audioBuffer || audioUrl) { + return ( + <div className="flex size-full items-center gap-2"> + <div className="min-w-0 flex-1"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + height={24} + className="w-full" + /> + </div> + </div> + ); + } + + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + if ( + mediaAsset.type === "image" || + (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) + ) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + const imageUrl = + mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; + + return ( + <div className="flex size-full items-center justify-center"> + <div + className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} + > + <div + className="absolute right-0 left-0" + style={{ + backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + top: isSelected ? "0.25rem" : "0rem", + bottom: isSelected ? "0.25rem" : "0rem", + }} + /> + </div> + </div> + ); + } + + return ( + <span className="text-foreground/80 truncate text-xs">{element.name}</span> + ); +} + +function CopyMenuItem() { + return ( + <ActionMenuItem + action="copy-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Copy + </ActionMenuItem> + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={VolumeMute02Icon} />; + } + return isMuted ? ( + <HugeiconsIcon icon={VolumeHighIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeOffIcon} /> + ); + }; + + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; + + return ( + <ActionMenuItem + action="toggle-elements-visibility-selected" + icon={getIcon()} + > + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + <ActionMenuItem + action="delete-selected" + variant="destructive" + icon={<HugeiconsIcon icon={Delete02Icon} />} + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + </ActionMenuItem> + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { + action: TAction; +}) { + return ( + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut(action)} + {...props} + > + {children} + </ContextMenuItem> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx new file mode 100644 index 00000000..8af35dc9 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { useEditor } from "@/hooks/use-editor"; + +interface TimelinePlayheadProps { + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement | null>; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + timelineRef: React.RefObject<HTMLDivElement | null>; + playheadRef?: React.RefObject<HTMLDivElement | null>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const timelineContainerHeight = + tracksScrollRef.current?.clientHeight ?? + timelineRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max(0, timelineContainerHeight - 4); + + const timelinePosition = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelinePosition; + + const handlePlayheadKeyDown = ( + event: React.KeyboardEvent<HTMLDivElement>, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + + event.preventDefault(); + const step = 1 / Math.max(1, editor.project.getActive().settings.fps); + const direction = event.key === "ArrowRight" ? 1 : -1; + const nextTime = Math.max( + 0, + Math.min(duration, playheadPosition + direction * step), + ); + + editor.playback.seek({ time: nextTime }); + }; + + return ( + <div + ref={playheadRef} + role="slider" + aria-label="Timeline playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={playheadPosition} + tabIndex={0} + className="pointer-events-auto absolute z-60" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + onMouseDown={handlePlayheadMouseDown} + onKeyDown={handlePlayheadKeyDown} + > + <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> + + <div + className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx new file mode 100644 index 00000000..b0261aa2 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -0,0 +1,126 @@ +import { type JSX, useLayoutEffect, useRef } from "react"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { DEFAULT_FPS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; +import { useScrollPosition } from "@/hooks/timeline/use-scroll-position"; +import { TimelineTick } from "./timeline-tick"; + +interface TimelineRulerProps { + zoomLevel: number; + dynamicTimelineWidth: number; + rulerRef: React.Ref<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLElement | null>; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineRuler({ + zoomLevel, + dynamicTimelineWidth, + rulerRef, + tracksScrollRef, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineRulerProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; + const effectiveDuration = Math.max(duration, visibleDuration); + const project = editor.project.getActive(); + const fps = project?.settings.fps ?? DEFAULT_FPS; + const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ + zoomLevel, + fps, + }); + const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + + const { scrollLeft, viewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); + + /** + * widens the virtualization buffer during zoom transitions. + * useScrollPosition lags one frame behind the scroll adjustment + * that useLayoutEffect applies after a zoom change. + */ + const prevZoomRef = useRef(zoomLevel); + const isZoomTransition = zoomLevel !== prevZoomRef.current; + const bufferPx = isZoomTransition + ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) + : 200; + + useLayoutEffect(() => { + prevZoomRef.current = zoomLevel; + }, [zoomLevel]); + + const visibleStartTime = Math.max( + 0, + (scrollLeft - bufferPx) / pixelsPerSecond, + ); + const visibleEndTime = + (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; + + const startTickIndex = Math.max( + 0, + Math.floor(visibleStartTime / tickIntervalSeconds), + ); + const endTickIndex = Math.min( + tickCount - 1, + Math.ceil(visibleEndTime / tickIntervalSeconds), + ); + + const timelineTicks: Array<JSX.Element> = []; + for ( + let tickIndex = startTickIndex; + tickIndex <= endTickIndex; + tickIndex += 1 + ) { + const time = tickIndex * tickIntervalSeconds; + if (time > effectiveDuration) break; + + const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); + timelineTicks.push( + <TimelineTick + key={tickIndex} + time={time} + zoomLevel={zoomLevel} + fps={fps} + showLabel={showLabel} + />, + ); + } + + return ( + <div + role="slider" + tabIndex={0} + aria-label="Timeline ruler" + aria-valuemin={0} + aria-valuemax={effectiveDuration} + aria-valuenow={0} + className="relative h-4 flex-1 overflow-x-visible" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={handleRulerTrackingMouseDown} + onKeyDown={() => {}} + > + <div + role="none" + ref={rulerRef} + className="relative h-4 cursor-default select-none" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {timelineTicks} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx new file mode 100644 index 00000000..9402d25e --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; + +interface TimelineTickProps { + time: number; + zoomLevel: number; + fps: number; + showLabel: boolean; +} + +export function TimelineTick({ + time, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds: time, fps }); + return ( + <span + className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none" + style={{ left: `${leftPosition}px` }} + > + {label} + </span> + ); + } + + return ( + <div + className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + style={{ left: `${leftPosition}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx new file mode 100644 index 00000000..0b02c63f --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -0,0 +1,285 @@ +import { useEditor } from "@/hooks/use-editor"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { SplitSquareHorizontal } from "lucide-react"; +import { + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, +} from "@/components/ui/split-button"; +import { Slider } from "@/components/ui/slider"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; +import { ScenesView } from "../../scenes-view"; +import { type TAction, invokeAction } from "@/lib/actions"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Bookmark02Icon, + Delete02Icon, + SnowIcon, + ScissorIcon, + MagnetIcon, + Link04Icon, + SearchAddIcon, + SearchMinusIcon, + Copy01Icon, + AlignLeftIcon, + AlignRightIcon, + Layers01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +export function TimelineToolbar({ + zoomLevel, + minZoom, + setZoomLevel, +}: { + zoomLevel: number; + minZoom: number; + setZoomLevel: ({ zoom }: { zoom: number }) => void; +}) { + const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const newZoomLevel = + direction === "in" + ? Math.min( + TIMELINE_CONSTANTS.ZOOM_MAX, + zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR, + ) + : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR); + setZoomLevel({ zoom: newZoomLevel }); + }; + + return ( + <ScrollArea className="scrollbar-hidden"> + <div className="flex h-10 items-center justify-between border-b px-2 py-1"> + <ToolbarLeftSection /> + + <SceneSelector /> + + <ToolbarRightSection + zoomLevel={zoomLevel} + minZoom={minZoom} + onZoomChange={(zoom) => setZoomLevel({ zoom })} + onZoom={handleZoom} + /> + </div> + </ScrollArea> + ); +} + +function ToolbarLeftSection() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + + const handleAction = ({ + action, + event, + }: { + action: TAction; + event: React.MouseEvent; + }) => { + event.stopPropagation(); + invokeAction(action); + }; + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={<HugeiconsIcon icon={ScissorIcon} />} + tooltip="Split element" + onClick={({ event }) => handleAction({ action: "split", event })} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={AlignLeftIcon} />} + tooltip="Split left" + onClick={({ event }) => handleAction({ action: "split-left", event })} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={AlignRightIcon} />} + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> + + <ToolbarButton + icon={<SplitSquareHorizontal />} + tooltip="Coming soon" /* separate audio */ + disabled={true} + onClick={({ event: _event }) => {}} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={Copy01Icon} />} + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={SnowIcon} />} + tooltip="Coming soon" /* freeze frame */ + disabled={true} + onClick={({ event: _event }) => {}} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={Delete02Icon} />} + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> + + <div className="bg-border mx-1 h-6 w-px" /> + + <Tooltip> + <ToolbarButton + icon={<HugeiconsIcon icon={Bookmark02Icon} />} + isActive={currentBookmarked} + tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + </Tooltip> + </TooltipProvider> + </div> + ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( + <div> + <SplitButton className="border-foreground/10 border"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight onClick={() => {}} type="button"> + <HugeiconsIcon icon={Layers01Icon} className="size-4" /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </div> + ); +} + +function ToolbarRightSection({ + zoomLevel, + minZoom, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + minZoom: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + const { + snappingEnabled, + rippleEditingEnabled, + toggleSnapping, + toggleRippleEditing, + } = useTimelineStore(); + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={<HugeiconsIcon icon={MagnetIcon} />} + isActive={snappingEnabled} + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />} + isActive={rippleEditingEnabled} + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + </TooltipProvider> + + <div className="bg-border mx-1 h-6 w-px" /> + + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "out" })} + > + <HugeiconsIcon icon={SearchMinusIcon} /> + </Button> + <Slider + className="w-28" + value={[zoomToSlider({ zoomLevel, minZoom })]} + onValueChange={(values) => + onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) + } + min={0} + max={1} + step={0.005} + /> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "in" })} + > + <HugeiconsIcon icon={SearchAddIcon} /> + </Button> + </div> + </div> + ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, + isActive, +}: { + icon: React.ReactNode; + tooltip: string; + onClick: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; + isActive?: boolean; +}) { + return ( + <Tooltip delayDuration={200}> + <TooltipTrigger asChild> + <Button + variant={isActive ? "secondary" : "text"} + size="icon" + type="button" + onClick={(event) => onClick({ event })} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} + > + {icon} + </Button> + </TooltipTrigger> + <TooltipContent>{tooltip}</TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx new file mode 100644 index 00000000..b7b06d5b --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { TimelineElement } from "./timeline-element"; +import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import type { ElementDragState } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onTrackMouseDown?: (event: React.MouseEvent) => void; + onTrackClick?: (event: React.MouseEvent) => void; + shouldIgnoreClick?: () => boolean; +} + +export function TimelineTrackContent({ + track, + zoomLevel, + dragState, + rulerScrollRef, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackClick, + shouldIgnoreClick, +}: TimelineTrackContentProps) { + const editor = useEditor(); + const { isElementSelected, clearElementSelection } = useElementSelection(); + + const duration = editor.timeline.getTotalDuration(); + + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current ?? 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); + + return ( + <button + className="size-full" + onClick={(event) => { + if (shouldIgnoreClick?.()) return; + clearElementSelection(); + onTrackClick?.(event); + }} + onMouseDown={(event) => { + event.preventDefault(); + onTrackMouseDown?.(event); + }} + type="button" + > + <div className="relative h-full min-w-full"> + {track.elements.length === 0 ? ( + <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + ) : ( + track.elements.map((element) => { + const isSelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onSnapPointChange={onSnapPointChange} + onResizeStateChange={onResizeStateChange} + onElementMouseDown={(event, element) => + onElementMouseDown({ event, element, track }) + } + onElementClick={(event, element) => + onElementClick({ event, element, track }) + } + dragState={dragState} + /> + ); + }) + )} + </div> + </button> + ); +} |
