From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../components/editor/panels/timeline/index.tsx | 885 +++++++++++++-------- 1 file changed, 571 insertions(+), 314 deletions(-) (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 index 593976a8..f1ecd9f4 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -3,31 +3,44 @@ 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 "@opencut/ui/icons"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../../../ui/context-menu"; +} from "@/components/ui/context-menu"; import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; -import { useState, useRef, useCallback } from "react"; +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 "../../selection-box"; -import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +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 "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { TIMELINE_CONSTANTS, - TRACK_CONFIG, + TRACK_GAP, + TIMELINE_SCROLLBAR_SIZE_PX, } from "@/constants/timeline-constants"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { @@ -36,12 +49,11 @@ import { getTotalTracksHeight, canTracktHaveAudio, canTrackBeHidden, + isMainTrack, 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"; @@ -49,27 +61,58 @@ 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 { 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 "@/lib/timeline/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 tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); - const { clearElementSelection, setElementSelection } = useElementSelection(); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const tracks = timeline.getTracks(); + const tracks = useEditor((editor) => editor.timeline.getTracks()); 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); @@ -110,8 +153,109 @@ export function Timeline() { initialScrollLeft: savedViewState?.scrollLeft, initialPlayheadTime: savedViewState?.playheadTime, tracksScrollRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, + }); + + // 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_CONSTANTS.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, @@ -124,7 +268,6 @@ export function Timeline() { timelineRef, tracksContainerRef, tracksScrollRef, - headerRef: timelineHeaderRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); @@ -144,14 +287,13 @@ export function Timeline() { useTimelinePlayhead({ zoomLevel, rulerRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, playheadRef, }); const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, tracksScrollRef, zoomLevel, }); @@ -161,17 +303,37 @@ export function Timeline() { handleMouseDown: handleSelectionMouseDown, isSelecting, shouldIgnoreClick, - } = useSelectionBox({ + } = useBoxSelect({ containerRef: tracksContainerRef, - headerRef: timelineHeaderRef, - onSelectionComplete: (elements) => { - setElementSelection({ elements }); + 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 }); + } }, - tracksScrollRef, - zoomLevel, }); - const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ @@ -183,11 +345,16 @@ export function Timeline() { 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, + rulerScrollRef, tracksScrollRef, contentWidth: dynamicTimelineWidth, }); @@ -205,7 +372,7 @@ export function Timeline() { } = useTimelineSeek({ playheadRef, trackLabelsRef, - rulerScrollRef: tracksScrollRef, + rulerScrollRef, tracksScrollRef, zoomLevel, duration: timeline.getTotalDuration(), @@ -214,13 +381,9 @@ export function Timeline() { seek, }); - useScrollSync({ - tracksScrollRef, - trackLabelsScrollRef, - }); - const timelineHeaderHeight = - timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONSTANTS.PADDING_TOP_PX || 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, - }) - } - /> - )} - -
-
- ))} -
- -
- )}
-
{ + syncFollowers(); + saveScrollPosition(); + }} > - - - - { - 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(); - }} +
+ {/* 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); }} > -
- 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, +}: { + trackLabelsRef: React.RefObject; + trackLabelsScrollRef: React.RefObject; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; +}) { + const editor = useEditor(); + const tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + return ( +
+
+
+
+ {tracks.length > 0 && ( +
+ {tracks.map((track) => (
- {tracks.length === 0 ? ( -
- ) : ( - [...tracks] - .map((track, index) => ({ track, index })) - .sort((a, b) => { - const aHasDragged = a.track.elements.some( - (element) => element.id === dragState.elementId, - ); - const bHasDragged = b.track.elements.some( - (element) => element.id === dragState.elementId, - ); - if (aHasDragged) return 1; - if (bHasDragged) return -1; - return 0; - }) - .map(({ track, index }) => ( - - -
- { - handleSelectionMouseDown(event); - handleTracksMouseDown(event); - }} - onTrackClick={handleTracksClick} - shouldIgnoreClick={shouldIgnoreClick} - targetElementId={ - isDragOver - ? dropTarget?.targetElement?.elementId ?? null - : null - } - /> -
-
- - } - onClick={(event) => { - event.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - - { - event.stopPropagation(); - timeline.toggleTrackMute({ - trackId: track.id, - }); - }} - > - - - {canTracktHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - - - { - event.stopPropagation(); - timeline.toggleTrackVisibility({ - trackId: track.id, - }); - }} - > - - - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - - - { - event.stopPropagation(); - timeline.removeTrack({ - trackId: track.id, - }); - }} - variant="destructive" - > - - Delete track - - -
- )) - )} +
+ {canTracktHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ trackId: track.id }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
-
- -
+ ))} +
+ )}
- +
+
+ ); +} + +function TimelineTrackRows({ + dragElementId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + dragElementId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject; + lastMouseXRef: React.RefObject; + onSnapPointChange: (snapPoint: SnapPoint | null) => void; + onResizeStateChange: (params: { isResizing: boolean }) => void; + 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 tracks = useEditor((e) => e.timeline.getTracks()); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const sortedTracks = useMemo( + () => + [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some( + (el) => el.id === dragElementId, + ); + const bHasDragged = b.track.elements.some( + (el) => el.id === dragElementId, + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }), + [tracks, dragElementId], ); + + 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 }); + }} + > + {canTracktHaveAudio(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"} + + {!isMainTrack(track) && ( + } + 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_CONFIG[track.type].icon}; + return <>{TRACK_ICONS[track.type]}; } function TrackToggleIcon({ -- cgit v1.3.1