From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/timeline/components/index.tsx | 946 +++++++++++++++++++++++++++++ 1 file changed, 946 insertions(+) create mode 100644 apps/web/src/timeline/components/index.tsx (limited to 'apps/web/src/timeline/components/index.tsx') diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx new file mode 100644 index 00000000..e6a9df49 --- /dev/null +++ b/apps/web/src/timeline/components/index.tsx @@ -0,0 +1,946 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Delete02Icon, + MagicWand05Icon, + MusicNote03Icon, + TaskAdd02Icon, + TextIcon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/timeline"; +import { TimelineTrackContent } from "./timeline-track"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SelectionBox } from "@/selection/selection-box"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineTrack } from "@/timeline"; +import { + TIMELINE_SCROLLBAR_SIZE_PX, + TIMELINE_CONTENT_TOP_PADDING_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, + KEYFRAME_LANE_HEIGHT_PX, +} from "./layout"; +import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction"; +import { + canTrackHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, +} from "@/timeline"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, +} from "./track-layout"; +import { SELECTED_TRACK_ROW_CLASS } from "./theme"; +import { + computeTrackExpansionHeight, + getTrackExpandedRows, + getPropertyLabel, + type ExpandedRow, +} from "./expanded-layout"; +import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; +import { TimelineToolbar } from "./timeline-toolbar"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek"; +import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { + TimelineBookmarksRow, + useBookmarkDrag, +} from "@/timeline/bookmarks/index"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom"; +import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { useEditor } from "@/editor/use-editor"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/actions"; +import { resolveTimelineElementIntersections } from "./selection-hit-testing"; +import { cn } from "@/utils/ui"; + +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; +const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; +const TRACK_ICONS: Record = { + video: , + text: ( + + ), + audio: ( + + ), + graphic: , + effect: ( + + ), +}; + +export function Timeline() { + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const scene = useEditor((currentEditor) => + currentEditor.scenes.getActiveSceneOrNull(), + ); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const mainTrackId = scene?.tracks.main.id ?? null; + const seek = (time: number) => editor.playback.seek({ time }); + + const timelineRef = useRef(null); + const timelineHeaderRef = useRef(null); + const rulerRef = useRef(null); + const rulerScrollRef = useRef(null); + const tracksContainerRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + + const [currentSnapPoint, setCurrentSnapPoint] = useState( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + 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, + }); + const { isResizing, handleResizeStart } = useTimelineResize({ + zoomLevel, + onSnapPointChange: handleSnapPointChange, + }); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + // Stable refs so the wheel listener never goes stale + const setZoomLevelRef = useRef(setZoomLevel); + useEffect(() => { + setZoomLevelRef.current = setZoomLevel; + }, [setZoomLevel]); + + const saveScrollPositionRef = useRef(saveScrollPosition); + useEffect(() => { + saveScrollPositionRef.current = saveScrollPosition; + }, [saveScrollPosition]); + + const minZoomLevelRef = useRef(minZoomLevel); + useEffect(() => { + minZoomLevelRef.current = minZoomLevel; + }, [minZoomLevel]); + + // Pushes tracks scroll position to the two overflow:hidden followers + // (ruler and track labels). Called from the wheel handler (before paint, + // zero lag) and from onScroll on the tracks area (covers scrollbar drag). + const syncFollowers = useCallback(() => { + const tracks = tracksScrollRef.current; + if (!tracks) return; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = tracks.scrollLeft; + } + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; + } + }, []); + + // Single non-passive capture listener owns all wheel input. Prevents any + // native scroll or browser zoom from firing inside the timeline. + useEffect(() => { + const container = timelineRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let zoomRafId: ReturnType | null = null; + + const onWheel = (e: WheelEvent) => { + const isZoom = e.ctrlKey || e.metaKey; + + if (isZoom) { + e.preventDefault(); + const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + pendingZoomDelta += normalizedDelta; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const frameRawDelta = pendingZoomDelta; + const cappedDelta = + Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevelRef.current((prev) => prev * zoomFactor); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + return; + } + + const tracks = tracksScrollRef.current; + if (!tracks) return; + + const isHorizontal = + e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); + + e.preventDefault(); + + if (isHorizontal) { + const raw = + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + const clamped = + Math.sign(raw) * + Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX); + tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); + } else { + tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); + } + + syncFollowers(); + saveScrollPositionRef.current(); + }; + + container.addEventListener("wheel", onWheel, { + passive: false, + capture: true, + }); + return () => { + container.removeEventListener("wheel", onWheel, { capture: true }); + if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); + }; + }, [syncFollowers]); + + useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll: () => saveScrollPositionRef.current(), + isReady: tracks.length > 0, + }); + + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + tracksScrollRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useBoxSelect({ + containerRef: tracksContainerRef, + selectedIds: selectedElements, + anchorId: null, + getIsAdditiveSelection: (event) => + event.shiftKey || event.ctrlKey || event.metaKey, + resolveIntersections: ({ startPos, currentPos }) => { + if (!tracksContainerRef.current) { + return []; + } + + return resolveTimelineElementIntersections({ + container: tracksContainerRef.current, + scrollContainer: tracksScrollRef.current, + tracks, + zoomLevel, + startPos, + currentPos, + }); + }, + onSelectionChange: ({ intersectedIds, isAdditive }) => { + if (isAdditive) { + mergeElementsIntoSelection({ elements: intersectedIds }); + } else { + setElementSelection({ elements: intersectedIds }); + } + }, + }); + + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const contentWidth = timelineTimeToPixels({ + time: timelineDuration, + zoomLevel, + }); + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + contentWidth + paddingPx, + containerWidth, + ); + const tracksViewportWidth = + tracksScrollRef.current?.clientWidth ?? + tracksContainerRef.current?.clientWidth ?? + containerWidth; + const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + + useEdgeAutoScroll({ + isActive: bookmarkDragState.isDragging, + getMouseClientX: () => bookmarkLastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + const timelineHeaderHeight = + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONTENT_TOP_PADDING_PX || 0; + + return ( +
+ setZoomLevel(zoom)} + /> + +
+ + +
+ + + + +
+
+ + +
+
+ + { + syncFollowers(); + saveScrollPosition(); + }} + > +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} +
{ + 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); + }} + > + {tracks.length > 0 && ( + { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} + /> + )} +
+ { + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={handleTracksClick} + /> +
+
+ + +
+ +
+
+ ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, + getTrackExpansionHeight, +}: { + trackLabelsRef: React.RefObject; + trackLabelsScrollRef: React.RefObject; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; + getTrackExpansionHeight: (trackIndex: number) => number; +}) { + const editor = useEditor(); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + const trackExpandedRowsMap = useMemo( + () => + tracks.map((track) => + getTrackExpandedRows({ track, expandedElementIds }), + ), + [tracks, expandedElementIds], + ); + + return ( +
+
+
+
+ {tracks.length > 0 && ( +
+ {tracks.map((track, index) => { + const expandedRows = trackExpandedRowsMap[index]; + const baseHeight = getTrackHeight({ type: track.type }); + + return ( +
+
+ {canTrackHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+ {expandedRows.length > 0 && ( + + )} +
+ ); + })} +
+ )} +
+
+
+
+ ); +} + +function TimelineTrackRows({ + mainTrackId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onResizeStart, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + mainTrackId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject; + lastMouseXRef: React.RefObject; + onResizeStart: React.ComponentProps< + typeof TimelineTrackContent + >["onResizeStart"]; + onElementMouseDown: React.ComponentProps< + typeof TimelineTrackContent + >["onElementMouseDown"]; + onElementClick: React.ComponentProps< + typeof TimelineTrackContent + >["onElementClick"]; + onTrackMouseDown: (event: React.MouseEvent) => void; + onTrackMouseUp: (event: React.MouseEvent) => void; + shouldIgnoreClick: () => boolean; + isDragOver: boolean; + dropTarget: DropTarget | null; +}) { + const timeline = useEditor((e) => e.timeline); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + const sortedTracks = useMemo(() => { + const draggingElementIds = new Set(dragState.dragElementIds); + return [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + const bHasDragged = b.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }); + }, [tracks, dragState.dragElementIds]); + + return ( + <> + {sortedTracks.map(({ track, index }) => ( + + +
+ +
+
+ + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackMute({ trackId: track.id }); + }} + > + {canTrackHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ trackId: track.id }); + }} + > + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + {track.id !== mainTrackId && ( + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.removeTrack({ trackId: track.id }); + }} + variant="destructive" + > + Delete track + + )} + +
+ ))} + + ); +} + +function TimelineGutter({ + onMouseDown, + onClick, +}: { + onMouseDown: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; +}) { + // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system + // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system + return
; +} + +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 ? ( + + ) : ( + + )} + + ); +} + +function PropertyTree({ rows }: { rows: ExpandedRow[] }) { + return ( +
+ {rows.map((row) => ( +
+ + {getPropertyLabel(row.propertyPath)} + +
+ ))} +
+ ); +} -- cgit v1.3.1