summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/timeline
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx271
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx27
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx5
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx885
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx470
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx38
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx26
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx62
11 files changed, 1066 insertions, 748 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index bbc5cb83..cbb19bd0 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,176 +1,167 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
+"use client";
+import { useCallback, useEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 2;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
- height?: number;
+ color?: string;
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,
+ color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
- const waveformRef = useRef<HTMLDivElement>(null);
- const wavesurfer = useRef<WaveSurfer | null>(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const bufferRef = useRef<AudioBuffer | null>(null);
+ const globalMaxRef = useRef<number>(1);
+ const scrollParentRef = useRef<HTMLElement | null>(null);
+ const heightRef = useRef<number>(0);
- useEffect(() => {
- let mounted = true;
- const ws = wavesurfer.current;
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const buffer = bufferRef.current;
+ const height = heightRef.current;
- const initWaveSurfer = async () => {
- if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+ if (!container || !canvas || !buffer || height <= 0) return;
- try {
- if (ws) {
- wavesurfer.current = null;
- }
+ const elementWidth = container.offsetWidth;
+ if (elementWidth <= 0) return;
- 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,
- });
+ const containerRect = container.getBoundingClientRect();
+ const scrollParent = scrollParentRef.current;
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- try {
- newWaveSurfer.destroy();
- } catch {}
- return;
- }
+ let clipLeft: number;
+ let clipRight: number;
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
+ } else {
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
+ }
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) return;
- 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);
- }
- }
- };
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.round(visibleWidth * dpr);
+ const canvasH = Math.round(height * dpr);
+
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
+
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const startFraction = clipLeft / elementWidth;
+ const endFraction = clipRight / elementWidth;
+ const startSample = Math.floor(startFraction * buffer.length);
+ const endSample = Math.min(
+ buffer.length,
+ Math.ceil(endFraction * buffer.length),
+ );
- if (ws) {
- const wsToDestroy = ws;
- wavesurfer.current = null;
+ const peaks = extractRmsRange({
+ buffer,
+ count: barCount,
+ startSample,
+ endSample,
+ globalMax: globalMaxRef.current,
+ });
- requestAnimationFrame(() => {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = color;
+
+ const maxBarHeight = height * 0.7;
+
+ for (let i = 0; i < barCount; i++) {
+ const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const barH = Math.max(1, scaled * maxBarHeight);
+ ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
+ }
+ }, [color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function load() {
+ let buffer = audioBuffer ?? null;
+
+ if (!buffer && audioUrl) {
try {
- wsToDestroy.destroy();
- } catch {}
- if (mounted) {
- initWaveSurfer();
+ const resp = await fetch(audioUrl);
+ const arrayBuffer = await resp.arrayBuffer();
+ const actx = new AudioContext();
+ buffer = await actx.decodeAudioData(arrayBuffer);
+ actx.close();
+ } catch {
+ return;
}
- });
- } else {
- initWaveSurfer();
+ }
+
+ if (!buffer || isCancelled) return;
+
+ bufferRef.current = buffer;
+ globalMaxRef.current = computeGlobalMaxRms({ buffer });
+ drawVisible();
}
+ load();
return () => {
- mounted = false;
+ isCancelled = true;
+ };
+ }, [audioUrl, audioBuffer, drawVisible]);
- const wsToDestroy = wavesurfer.current;
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
- wavesurfer.current = null;
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) return;
- if (wsToDestroy) {
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- });
- }
- };
- }, [audioUrl, audioBuffer, height]);
+ scrollParent.addEventListener("scroll", drawVisible, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", drawVisible);
+ }, [drawVisible]);
- if (error) {
- return (
- <div
- className={`flex items-center justify-center ${className}`}
- style={{ height }}
- >
- <span className="text-foreground/60 text-xs">Audio unavailable</span>
- </div>
- );
- }
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
return (
- <div className={`relative ${className}`}>
- {isLoading && (
- <div className="absolute inset-0 flex items-center justify-center">
- <span className="text-foreground/60 text-xs">Loading...</span>
- </div>
- )}
- <div
- ref={waveformRef}
- className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
- style={{ height }}
- />
+ <div ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
</div>
);
}
-
-export default AudioWaveform;
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index 864bb557..7000f98b 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,7 +5,10 @@ import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_BOOKMARK_ROW_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -13,7 +16,7 @@ import {
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
import {
Popover,
PopoverAnchor,
@@ -25,7 +28,10 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
-import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
+import {
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -72,14 +78,17 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
- const editor = useEditor();
- const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
return (
- <div className="relative h-4 flex-1 overflow-hidden">
+ <div
+ className="relative flex-1 overflow-hidden"
+ style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
+ >
<button
- className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
+ className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
+ height: TIMELINE_BOOKMARK_ROW_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
@@ -95,7 +104,7 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown(event);
}}
>
- {activeScene.bookmarks.map((bookmark) => (
+ {bookmarks.map((bookmark) => (
<TimelineBookmark
key={`bookmark-${bookmark.time}`}
bookmark={bookmark}
@@ -174,7 +183,7 @@ function TimelineBookmark({
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverAnchor asChild>
<button
- className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
+ className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
style={{
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index 291a4633..7597a620 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -1,5 +1,6 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
-import type { TimelineTrack, DropTarget } from "@/types/timeline";
+import type { TimelineTrack, DropTarget } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface DragLineProps {
dropTarget: DropTarget | null;
@@ -22,7 +23,7 @@ export function DragLine({
return (
<div
className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
- style={{ top: `${lineTop}px` }}
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
/>
);
}
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<TimelineTrack["type"], ReactNode> = {
+ video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
+ text: (
+ <HugeiconsIcon
+ icon={TextIcon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ audio: (
+ <HugeiconsIcon
+ icon={MusicNote03Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
+ effect: (
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+};
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<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(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<typeof requestAnimationFrame> | 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 (
<section
@@ -236,315 +399,409 @@ export function Timeline() {
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
- <div
- className="relative flex flex-1 flex-col overflow-hidden"
- ref={timelineRef}
- >
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- tracks={tracks}
- timelineRef={timelineRef}
+ <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
+ <TrackLabelsPanel
trackLabelsRef={trackLabelsRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
+ trackLabelsScrollRef={trackLabelsScrollRef}
+ timelineHeaderHeight={timelineHeaderHeight}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
/>
- <div className="flex flex-1 overflow-hidden">
- <div className="bg-background flex w-28 shrink-0 flex-col border-r">
- <div className="bg-background flex h-4 items-center justify-between px-3">
- <span className="opacity-0">.</span>
- </div>
- <div className="bg-background flex h-4 items-center justify-between px-3">
- <span className="opacity-0">.</span>
+
+ <div
+ className="relative isolate flex flex-1 flex-col overflow-hidden"
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <DragLine
+ dropTarget={dropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={isDragOver && !dropTarget?.targetElement}
+ headerHeight={timelineHeaderHeight}
+ />
+ <DragLine
+ dropTarget={dragDropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={dragState.isDragging}
+ headerHeight={timelineHeaderHeight}
+ />
+
+ <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
+ <div
+ ref={timelineHeaderRef}
+ className="flex flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ tracksScrollRef={rulerScrollRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
</div>
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="bg-background flex-1 overflow-y-auto"
- style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
- >
- <ScrollArea className="size-full" ref={trackLabelsScrollRef}>
- <div className="flex flex-col gap-1">
- {tracks.map((track) => (
- <div
- key={track.id}
- className="group flex items-center px-3"
- style={{
- height: `${getTrackHeight({ type: track.type })}px`,
- }}
- >
- <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
- {process.env.NODE_ENV === "development" &&
- isMainTrack(track) && (
- <div className="bg-red-500 size-1.5 rounded-full" />
- )}
- {canTracktHaveAudio(track) && (
- <TrackToggleIcon
- isOff={track.muted}
- icons={{
- on: VolumeHighIcon,
- off: VolumeOffIcon,
- }}
- onClick={() =>
- editor.timeline.toggleTrackMute({
- trackId: track.id,
- })
- }
- />
- )}
- {canTrackBeHidden(track) && (
- <TrackToggleIcon
- isOff={track.hidden}
- icons={{
- on: ViewIcon,
- off: ViewOffSlashIcon,
- }}
- onClick={() =>
- editor.timeline.toggleTrackVisibility({
- trackId: track.id,
- })
- }
- />
- )}
- <TrackIcon track={track} />
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </div>
- )}
</div>
- <div
- className="relative flex flex-1 flex-col overflow-hidden"
- ref={tracksContainerRef}
+ <ScrollArea
+ className="flex-1"
+ ref={tracksScrollRef}
+ onScroll={() => {
+ syncFollowers();
+ saveScrollPosition();
+ }}
>
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <DragLine
- dropTarget={dropTarget}
- tracks={timeline.getTracks()}
- isVisible={isDragOver && !dropTarget?.targetElement}
- headerHeight={timelineHeaderHeight}
- />
- <DragLine
- dropTarget={dragDropTarget}
- tracks={timeline.getTracks()}
- isVisible={dragState.isDragging}
- headerHeight={timelineHeaderHeight}
- />
- <ScrollArea
- className="size-full overflow-y-hidden"
- ref={tracksScrollRef}
- onMouseDown={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksClick(event);
- }}
- onWheel={(event) => {
- if (
- event.shiftKey ||
- Math.abs(event.deltaX) > Math.abs(event.deltaY)
- ) {
- return;
- }
- handleWheel(event);
- }}
- onScroll={() => {
- saveScrollPosition();
- }}
+ <div
+ className="flex min-h-full flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
>
+ {/* 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 */}
<div
- className="relative"
+ className="relative shrink-0"
style={{
- width: `${dynamicTimelineWidth}px`,
+ height: `${
+ Math.max(
+ TRACKS_CONTAINER_HEIGHT.min,
+ Math.min(
+ TRACKS_CONTAINER_HEIGHT.max,
+ getTotalTracksHeight({ tracks }),
+ ),
+ ) + TIMELINE_CONSTANTS.PADDING_TOP_PX
+ }px`,
+ }}
+ onMouseDown={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
}}
>
- <div
- ref={timelineHeaderRef}
- className="bg-background sticky top-0 flex flex-col"
- >
- <TimelineRuler
+ {tracks.length > 0 && (
+ <TimelineTrackRows
+ dragElementId={dragState.elementId}
zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- rulerRef={rulerRef}
+ dragState={dragState}
tracksScrollRef={tracksScrollRef}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- <TimelineBookmarksRow
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- dragState={bookmarkDragState}
- onBookmarkMouseDown={handleBookmarkMouseDown}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={handleSnapPointChange}
+ onResizeStateChange={handleResizeStateChange}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackMouseUp={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ isDragOver={isDragOver}
+ dropTarget={dropTarget}
/>
- </div>
- <TimelinePlayhead
- zoomLevel={zoomLevel}
- rulerRef={rulerRef}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
+ )}
+ </div>
+ <TimelineGutter
+ onMouseDown={(event) => {
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={handleTracksClick}
+ />
+ </div>
+ </ScrollArea>
+
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ </div>
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ timelineRef={timelineRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ tracks={tracks}
+ />
+ </div>
+ </section>
+ );
+}
+
+function TrackLabelsPanel({
+ trackLabelsRef,
+ trackLabelsScrollRef,
+ timelineHeaderHeight,
+ hasHorizontalScrollbar,
+}: {
+ trackLabelsRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
+ 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 (
+ <div className="flex w-28 shrink-0 flex-col border-r">
+ <div
+ className="shrink-0"
+ style={{ height: timelineHeaderHeight || 48 }}
+ />
+ <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
+ <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
+ {tracks.length > 0 && (
+ <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
+ {tracks.map((track) => (
<div
- className="relative"
+ key={track.id}
+ className={cn(
+ "group flex items-center px-3",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
style={{
- height: `${Math.max(
- tracksContainerHeight.min,
- Math.min(
- tracksContainerHeight.max,
- getTotalTracksHeight({ tracks }),
- ),
- )}px`,
+ height: `${getTrackHeight({ type: track.type })}px`,
}}
>
- {tracks.length === 0 ? (
- <div />
- ) : (
- [...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 }) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className="absolute right-0 left-0"
- style={{
- top: `${getCumulativeHeightBefore({
- tracks,
- trackIndex: index,
- })}px`,
- height: `${getTrackHeight({
- type: track.type,
- })}px`,
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- dragState={dragState}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onSnapPointChange={handleSnapPointChange}
- onResizeStateChange={handleResizeStateChange}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- onTrackMouseDown={(event) => {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackClick={handleTracksClick}
- shouldIgnoreClick={shouldIgnoreClick}
- targetElementId={
- isDragOver
- ? dropTarget?.targetElement?.elementId ?? null
- : null
- }
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-40">
- <ContextMenuItem
- icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(event) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
- variant="destructive"
- >
- <HugeiconsIcon icon={Delete02Icon} />
- Delete track
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))
- )}
+ <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
+ {canTracktHaveAudio(track) && (
+ <TrackToggleIcon
+ isOff={track.muted}
+ icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
+ onClick={() =>
+ editor.timeline.toggleTrackMute({ trackId: track.id })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+ <TrackToggleIcon
+ isOff={track.hidden}
+ icons={{ on: ViewIcon, off: ViewOffSlashIcon }}
+ onClick={() =>
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ <TrackIcon track={track} />
+ </div>
</div>
- </div>
- </ScrollArea>
- </div>
+ ))}
+ </div>
+ )}
</div>
</div>
- </section>
+ <div
+ className="bg-background shrink-0"
+ style={{
+ height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
+ }}
+ />
+ </div>
+ );
+}
+
+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<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ 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 }) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className={cn(
+ "absolute right-0 left-0 transition-colors",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
+ style={{
+ top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
+ height: `${getTrackHeight({ type: track.type })}px`,
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={onElementMouseDown}
+ onElementClick={onElementClick}
+ onTrackMouseDown={onTrackMouseDown}
+ onTrackMouseUp={onTrackMouseUp}
+ shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? (dropTarget?.targetElement?.elementId ?? null)
+ : null
+ }
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ >
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={ViewIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({ trackId: track.id });
+ }}
+ >
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </ContextMenuItem>
+ {!isMainTrack(track) && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.removeTrack({ trackId: track.id });
+ }}
+ variant="destructive"
+ >
+ Delete track
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ );
+}
+
+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 <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_CONFIG[track.type].icon}</>;
+ return <>{TRACK_ICONS[track.type]}</>;
}
function TrackToggleIcon({
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 927d336a..acb57203 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -6,15 +6,12 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
isVisible: boolean;
- tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
@@ -22,17 +19,13 @@ export function SnapIndicator({
snapPoint,
zoomLevel,
isVisible,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: SnapIndicatorProps) {
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
});
@@ -48,6 +41,7 @@ export function SnapIndicator({
top: topPosition,
height: `${height}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 9121f6c2..13a31375 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import AudioWaveform from "./audio-waveform";
+import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import {
useKeyframeDrag,
@@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getTrackClasses,
+ getElementClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
+ hasElementEffects,
hasMediaId,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
import {
ContextMenu,
ContextMenuContent,
@@ -30,14 +32,22 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
- VisualElement,
ElementDragState,
-} from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
import Image from "next/image";
import {
ScissorIcon,
@@ -56,13 +66,16 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
-import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-import { usePropertiesStore } from "@/stores/properties-store";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
time: number;
@@ -195,16 +208,15 @@ export function TimelineElement({
dragState,
isDropTarget = false,
}: TimelineElementProps) {
- const editor = useEditor();
+ const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const { requestRevealMedia } = useAssetsPanelStore();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
- null;
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -394,60 +406,46 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
- const opacityClass =
- (canElementBeHidden(element) && element.hidden) || isDropTarget
- ? "opacity-50"
- : "";
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
-
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
return (
<div
- className="relative h-full cursor-pointer"
- style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
>
<div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- getTrackClasses({ type: track.type }),
- opacityClass,
- )}
+ className="absolute inset-0 rounded-sm"
style={
isSelected
? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
}
: undefined
}
>
- <button
- type="button"
- className="absolute inset-0 size-full cursor-pointer flex flex-col"
- onClick={(event) => {
- closeClipEffects();
- onElementClick(event, element);
- }}
- onMouseDown={(event) => onElementMouseDown(event, element)}
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
>
- <div className="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent
- element={element}
- track={track}
- isSelected={isSelected}
- />
- </div>
- </button>
- </div>
-
- {element.type !== "audio" && element.type !== "effect" && (
- <div className="sticky left-1 mt-1 ml-1 w-fit">
- <EffectsButton
- element={element as VisualElement}
- trackId={track.id}
- />
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
</div>
- )}
+ </div>
{isSelected && (
<>
@@ -535,8 +533,8 @@ function KeyframeIndicators({
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
- const isBeingDragged = indicator.keyframes.some((kf) =>
- dragState.draggingKeyframeIds.has(kf.keyframeId),
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
);
const visualOffsetPx = getVisualOffsetPx({
indicatorTime: indicator.time,
@@ -581,212 +579,244 @@ function KeyframeIndicators({
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
- isSelected: boolean;
}
-interface ElementContentRendererProps extends ElementContentProps {
- mediaAssets: MediaAsset[];
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
}
-type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
-export function renderTiledMedia({
+function GraphicElementContent({
element,
- imageUrl,
- track,
}: {
- element: VisualElement;
- imageUrl: string | undefined;
- track: ElementContentProps["track"];
-}): ReactNode {
- if (!imageUrl) {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
+ <div className="relative size-full">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ />
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ </div>
);
}
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
-
return (
- <div
- className="absolute inset-0"
- style={{
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
}
function EffectsButton({
element,
- trackId,
- className,
+ track,
}: {
- element: VisualElement;
- trackId: string;
- className?: string;
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
}) {
- const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
- const { selectElement } = useElementSelection();
-
- if (!element.effects?.length) {
- return null;
- }
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
const handleClick = (event: React.MouseEvent) => {
event.stopPropagation();
- selectElement({ elementId: element.id, trackId });
- openClipEffects({ elementId: element.id, trackId });
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
};
return (
- <Button
- variant="text"
- size="icon"
- className={cn("rounded-sm !size-5 bg-black/50 text-white", className)}
- onClick={handleClick}
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
>
- <HugeiconsIcon icon={MagicWand05Icon} />
- </Button>
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
);
}
-const ELEMENT_CONTENT_RENDERERS: Record<
- TimelineElementType["type"],
- ElementContentRenderer
-> = {
- text: ({ element }) => {
- const textElement = element as Extract<
- TimelineElementType,
- { type: "text" }
- >;
- return (
- <div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">
- {textElement.content}
- </span>
- </div>
- );
- },
- effect: ({ element }) => (
- <div className="flex size-full items-center justify-start gap-1 pl-2">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-4 shrink-0 text-white"
- />
- <span className="truncate text-xs text-white ml-1">{element.name}</span>
- </div>
- ),
- sticker: ({ element }) => {
- const stickerElement = element as Extract<
- TimelineElementType,
- { type: "sticker" }
- >;
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: stickerElement.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={stickerElement.name}
- className="size-5 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">
- {stickerElement.name}
- </span>
- </div>
- );
- },
- audio: ({ element, mediaAssets }) => {
- const audioElement = element as Extract<
- TimelineElementType,
- { type: "audio" }
- >;
- const audioBuffer =
- audioElement.sourceType === "library" ? audioElement.buffer : undefined;
- const audioUrl =
- audioElement.sourceType === "library"
- ? audioElement.sourceUrl
- : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
- if (audioBuffer || audioUrl) {
- return (
- <div className="flex size-full items-center gap-2">
- <div className="min-w-0 flex-1">
- <AudioWaveform
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+ if (!imageUrl) {
return (
<span className="text-foreground/80 truncate text-xs">
- {audioElement.name}
+ {element.name}
</span>
);
- },
- video: ({ element, track, mediaAssets }) => {
- const videoElement = element as Extract<
- TimelineElementType,
- { type: "video" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === videoElement.mediaId,
- );
- return renderTiledMedia({
- element: videoElement,
- imageUrl: mediaAsset?.thumbnailUrl,
- track,
- });
- },
- image: ({ element, track, mediaAssets }) => {
- const imageElement = element as Extract<
- TimelineElementType,
- { type: "image" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === imageElement.mediaId,
- );
- return renderTiledMedia({
- element: imageElement,
- imageUrl: mediaAsset?.url,
- track,
- });
- },
-};
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-function ElementContent({ element, track, isSelected }: ElementContentProps) {
- const editor = useEditor();
- const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
<>
- {renderer({
- element,
- track,
- isSelected,
- mediaAssets: editor.media.getAssets(),
- })}
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
</>
);
}
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
function CopyMenuItem() {
return (
<ActionMenuItem
@@ -886,7 +916,7 @@ function ActionMenuItem({
children,
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
- action: TAction;
+ action: TActionWithOptionalArgs;
children: ReactNode;
}) {
return (
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index e290259e..1abe65bd 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -8,9 +8,14 @@ import {
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
+import {
+ TIMELINE_LAYERS,
+ TIMELINE_SCROLLBAR_SIZE_PX,
+} from "@/constants/timeline-constants";
interface TimelinePlayheadProps {
zoomLevel: number;
+ hasHorizontalScrollbar: boolean;
rulerRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
@@ -21,6 +26,7 @@ interface TimelinePlayheadProps {
export function TimelinePlayhead({
zoomLevel,
+ hasHorizontalScrollbar,
rulerRef,
rulerScrollRef,
tracksScrollRef,
@@ -33,7 +39,7 @@ export function TimelinePlayhead({
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ const { handlePlayheadMouseDown } = useTimelinePlayhead({
zoomLevel,
rulerRef,
rulerScrollRef,
@@ -42,16 +48,23 @@ export function TimelinePlayhead({
});
const timelineContainerHeight =
- tracksScrollRef.current?.clientHeight ??
timelineRef.current?.clientHeight ??
+ tracksScrollRef.current?.clientHeight ??
400;
- const totalHeight = Math.max(0, timelineContainerHeight - 4);
+ const totalHeight = Math.max(
+ 0,
+ timelineContainerHeight -
+ (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
+ );
+ const currentTime = editor.playback.getCurrentTime();
const centerPosition = timelineTimeToSnappedPixels({
- time: playheadPosition,
+ time: currentTime,
zoomLevel,
});
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
+ const leftPosition =
+ getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@@ -61,10 +74,8 @@ export function TimelinePlayhead({
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),
- );
+ const now = editor.playback.getCurrentTime();
+ const nextTime = Math.max(0, Math.min(duration, now + direction * step));
editor.playback.seek({ time: nextTime });
};
@@ -76,23 +87,24 @@ export function TimelinePlayhead({
aria-label="Timeline playhead"
aria-valuemin={0}
aria-valuemax={duration}
- aria-valuenow={playheadPosition}
+ aria-valuenow={currentTime}
tabIndex={0}
- className="pointer-events-none absolute z-5"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.playhead,
}}
onKeyDown={handlePlayheadKeyDown}
>
- <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
+ <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
<button
type="button"
aria-label="Drag playhead"
- className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
onMouseDown={handlePlayheadMouseDown}
/>
</div>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index b0261aa2..09562f23 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -1,5 +1,8 @@
import { type JSX, useLayoutEffect, useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ TIMELINE_RULER_HEIGHT,
+} 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";
@@ -27,13 +30,12 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.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 fps =
+ useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
@@ -104,17 +106,25 @@ export function TimelineRuler({
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuenow={0}
- className="relative h-4 flex-1 overflow-x-visible"
+ className="relative flex-1 overflow-x-visible"
+ style={{ height: TIMELINE_RULER_HEIGHT }}
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ // Ruler seek already happens on mousedown via playhead scrubbing.
+ // Forwarding the follow-up click re-enters the selection-clearing path.
+ if (event.target === event.currentTarget) {
+ handleTimelineContentClick(event);
+ }
+ }}
onMouseDown={handleRulerTrackingMouseDown}
onKeyDown={() => {}}
>
<div
role="none"
ref={rulerRef}
- className="relative h-4 cursor-default select-none"
+ className="relative cursor-default select-none"
style={{
+ height: TIMELINE_RULER_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 22cd52cf..29fd513b 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -22,7 +22,7 @@ export function TimelineTick({
const label = formatRulerLabel({ timeInSeconds: time, fps });
return (
<span
- className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
@@ -32,7 +32,7 @@ export function TimelineTick({
return (
<div
- className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 533ea63d..e785d147 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -16,8 +16,8 @@ import {
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 { ScenesView } from "@/components/editor/scenes-view";
+import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -76,15 +76,15 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = useEditor((e) =>
+ e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
+ );
const handleAction = ({
action,
event,
}: {
- action: TAction;
+ action: TActionWithOptionalArgs;
event: React.MouseEvent;
}) => {
event.stopPropagation();
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 81cda7a6..6a9e4dfe 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -2,12 +2,12 @@
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 { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/types/timeline";
+import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
@@ -30,7 +30,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackClick?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
@@ -47,14 +47,12 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
- onTrackClick,
+ onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
- const editor = useEditor();
const { isElementSelected } = useElementSelection();
-
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
@@ -65,21 +63,37 @@ export function TimelineTrackContent({
});
return (
- <button
- className="size-full"
- onClick={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackClick?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- type="button"
- >
- <div className="relative h-full min-w-full">
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
{track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
@@ -109,6 +123,6 @@ export function TimelineTrackContent({
})
)}
</div>
- </button>
+ </div>
);
}