From fca99d6126c31fbb18ed9f1034cee6f940b040e8 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 10 Feb 2026 16:28:21 +0100 Subject: refactor --- .../editor/panels/timeline/audio-waveform.tsx | 176 +++++++ .../editor/panels/timeline/bookmarks.tsx | 111 ++++ .../editor/panels/timeline/drag-line.tsx | 28 + .../components/editor/panels/timeline/index.tsx | 535 +++++++++++++++++++ .../editor/panels/timeline/snap-indicator.tsx | 52 ++ .../editor/panels/timeline/timeline-element.tsx | 566 +++++++++++++++++++++ .../editor/panels/timeline/timeline-playhead.tsx | 92 ++++ .../editor/panels/timeline/timeline-ruler.tsx | 126 +++++ .../editor/panels/timeline/timeline-tick.tsx | 39 ++ .../editor/panels/timeline/timeline-toolbar.tsx | 285 +++++++++++ .../editor/panels/timeline/timeline-track.tsx | 112 ++++ 11 files changed, 2122 insertions(+) create mode 100644 apps/web/src/components/editor/panels/timeline/audio-waveform.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/bookmarks.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/drag-line.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/index.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/snap-indicator.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-element.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-tick.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/timeline-track.tsx (limited to 'apps/web/src/components/editor/panels/timeline') 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(null); + const wavesurfer = useRef(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 ( +
+ Audio unavailable +
+ ); + } + + return ( +
+ {isLoading && ( +
+ Loading... +
+ )} +
+
+ ); +} + +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 ( +
+ +
+ ); +} + +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 + | React.KeyboardEvent; + }) => { + event.stopPropagation(); + const framesPerSecond = activeProject?.settings.fps ?? 30; + const snappedTime = getSnappedSeekTime({ + rawTime: time, + duration, + fps: framesPerSecond, + }); + editor.playback.seek({ time: snappedTime }); + }; + + return ( + + ); +} 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 ( +
+ ); +} 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(null); + const timelineHeaderRef = useRef(null); + const rulerRef = useRef(null); + const tracksContainerRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + + // state + const [isResizing, setIsResizing] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState( + 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 ( +
+ setZoomLevel(zoom)} + /> + +
+ +
+
+
+ . +
+
+ . +
+ {tracks.length > 0 && ( +
+ +
+ {tracks.map((track) => ( +
+
+ {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( +
+ )} + {canTracktHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+
+ ))} +
+ +
+ )} +
+ +
+ + + + { + 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(); + }} + > +
+
+ + +
+ +
+ {tracks.length === 0 ? ( +
+ ) : ( + tracks.map((track, index) => ( + + +
+ { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackClick={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + /> +
+
+ + } + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + + + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + + { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + + + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + + { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + + Delete track + + +
+ )) + )} +
+
+ +
+
+
+
+ ); +} + +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 ? ( + + ) : ( + + )} + + ); +} 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; + trackLabelsRef?: React.RefObject; + tracksScrollRef: React.RefObject; +} + +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 ( +
+
+
+ ); +} 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 ( + + +
+ +
+
+ + } + > + Split + + + {canElementHaveAudio(element) && hasAudio && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canElementBeHidden(element) && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {selectedElements.length === 1 && ( + } + > + Duplicate + + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + } + onClick={(event) => handleRevealInMedia({ event })} + > + Reveal media + + } + disabled + > + Replace media + + + )} + + 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + +
+ ); +} + +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 ( +
+ + + {isSelected && ( + <> + + + + )} +
+ ); +} + +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 ( + + ); +} + +function ElementContent({ + element, + track, + isSelected, + mediaAssets, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + mediaAssets: MediaAsset[]; +}) { + if (element.type === "text") { + return ( +
+ {element.content} +
+ ); + } + + if (element.type === "sticker") { + return ( +
+ {element.name} + {element.name} +
+ ); + } + + 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 ( +
+
+ +
+
+ ); + } + + return ( + + {element.name} + + ); + } + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + if (!mediaAsset) { + return ( + + {element.name} + + ); + } + + 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 ( +
+
+
+
+
+ ); + } + + return ( + {element.name} + ); +} + +function CopyMenuItem() { + return ( + } + > + Copy + + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isMuted ? ( + + ) : ( + + ); + }; + + return ( + + {isMuted ? "Unmute" : "Mute"} + + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isHidden ? ( + + ) : ( + + ); + }; + + return ( + + {isHidden ? "Show" : "Hide"} + + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + } + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit, "onClick" | "textRight"> & { + action: TAction; +}) { + return ( + { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut(action)} + {...props} + > + {children} + + ); +} 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; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + timelineRef: React.RefObject; + playheadRef?: React.RefObject; + 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(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, + ) => { + 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 ( +
+
+ +
+
+ ); +} 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; + tracksScrollRef: React.RefObject; + 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 = []; + for ( + let tickIndex = startTickIndex; + tickIndex <= endTickIndex; + tickIndex += 1 + ) { + const time = tickIndex * tickIntervalSeconds; + if (time > effectiveDuration) break; + + const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); + timelineTicks.push( + , + ); + } + + return ( +
{}} + > +
+ {timelineTicks} +
+
+ ); +} 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 ( + + {label} + + ); + } + + return ( +
+ ); +} 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 ( + +
+ + + + + setZoomLevel({ zoom })} + onZoom={handleZoom} + /> +
+
+ ); +} + +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 ( +
+ + } + tooltip="Split element" + onClick={({ event }) => handleAction({ action: "split", event })} + /> + + } + tooltip="Split left" + onClick={({ event }) => handleAction({ action: "split-left", event })} + /> + + } + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> + + } + tooltip="Coming soon" /* separate audio */ + disabled={true} + onClick={({ event: _event }) => {}} + /> + + } + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> + + } + tooltip="Coming soon" /* freeze frame */ + disabled={true} + onClick={({ event: _event }) => {}} + /> + + } + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> + +
+ + + } + isActive={currentBookmarked} + tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + + +
+ ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( +
+ + {currentScene?.name || "No Scene"} + + + {}} type="button"> + + + + +
+ ); +} + +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 ( +
+ + } + isActive={snappingEnabled} + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> + + } + isActive={rippleEditingEnabled} + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + + +
+ +
+ + + onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) + } + min={0} + max={1} + step={0.005} + /> + +
+
+ ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, + isActive, +}: { + icon: React.ReactNode; + tooltip: string; + onClick: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; + isActive?: boolean; +}) { + return ( + + + + + {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; + tracksScrollRef: React.RefObject; + lastMouseXRef: React.RefObject; + 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 ( + + ); +} -- cgit v1.3.1