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 --- .../components/editor/panels/timeline/index.tsx | 944 --------------------- 1 file changed, 944 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/timeline/index.tsx (limited to 'apps/web/src/components/editor/panels/timeline/index.tsx') diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx deleted file mode 100644 index 20e9faf1..00000000 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ /dev/null @@ -1,944 +0,0 @@ -"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 "@/hooks/timeline/use-timeline-zoom"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import type { ElementDragState, DropTarget } from "@/lib/timeline"; -import { TimelineTrackContent } from "./timeline-track"; -import { TimelinePlayhead } from "./timeline-playhead"; -import { SelectionBox } from "@/lib/selection/selection-box"; -import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; -import { SnapIndicator } from "./snap-indicator"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import type { TimelineTrack } from "@/lib/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 "@/hooks/timeline/element/use-element-interaction"; -import { - canTrackHaveAudio, - canTrackBeHidden, - getTimelineZoomMin, - getTimelinePaddingPx, -} from "@/lib/timeline"; -import { timelineTimeToPixels } from "@/lib/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 "@/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 { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom"; -import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize"; -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"; -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