summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/timeline')
-rw-r--r--apps/web/src/components/editor/timeline/audio-waveform.tsx176
-rw-r--r--apps/web/src/components/editor/timeline/bookmarks.tsx111
-rw-r--r--apps/web/src/components/editor/timeline/drag-line.tsx28
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx1385
-rw-r--r--apps/web/src/components/editor/timeline/snap-indicator.tsx52
-rw-r--r--apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx126
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx861
-rw-r--r--apps/web/src/components/editor/timeline/timeline-marker.tsx67
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx228
-rw-r--r--apps/web/src/components/editor/timeline/timeline-ruler.tsx83
-rw-r--r--apps/web/src/components/editor/timeline/timeline-tick.tsx39
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx696
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx1290
13 files changed, 2001 insertions, 3141 deletions
diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx
new file mode 100644
index 00000000..bbc5cb83
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx
@@ -0,0 +1,176 @@
+import { useEffect, useRef, useState } from "react";
+import WaveSurfer from "wavesurfer.js";
+
+interface AudioWaveformProps {
+ audioUrl?: string;
+ audioBuffer?: AudioBuffer;
+ height?: number;
+ className?: string;
+}
+
+function extractPeaks({
+ buffer,
+ length = 512,
+}: {
+ buffer: AudioBuffer;
+ length?: number;
+}): number[][] {
+ const channels = buffer.numberOfChannels;
+ const peaks: number[][] = [];
+
+ for (let c = 0; c < channels; c++) {
+ const data = buffer.getChannelData(c);
+ const step = Math.floor(data.length / length);
+ const channelPeaks: number[] = [];
+
+ for (let i = 0; i < length; i++) {
+ const start = i * step;
+ const end = Math.min(start + step, data.length);
+ let max = 0;
+ for (let j = start; j < end; j++) {
+ const abs = Math.abs(data[j]);
+ if (abs > max) max = abs;
+ }
+ channelPeaks.push(max);
+ }
+ peaks.push(channelPeaks);
+ }
+
+ return peaks;
+}
+
+export function AudioWaveform({
+ audioUrl,
+ audioBuffer,
+ height = 32,
+ className = "",
+}: AudioWaveformProps) {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ const ws = wavesurfer.current;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+
+ try {
+ if (ws) {
+ wavesurfer.current = null;
+ }
+
+ const newWaveSurfer = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: "rgba(255, 255, 255, 0.6)",
+ progressColor: "rgba(255, 255, 255, 0.9)",
+ cursorColor: "transparent",
+ barWidth: 2,
+ barGap: 1,
+ height,
+ normalize: true,
+ interact: false,
+ });
+
+ if (mounted) {
+ wavesurfer.current = newWaveSurfer;
+ } else {
+ try {
+ newWaveSurfer.destroy();
+ } catch {}
+ return;
+ }
+
+ newWaveSurfer.on("ready", () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ newWaveSurfer.on("error", (err) => {
+ if (mounted) {
+ console.error("WaveSurfer error:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ if (audioBuffer) {
+ const peaks = extractPeaks({ buffer: audioBuffer });
+ newWaveSurfer.load("", peaks, audioBuffer.duration);
+ } else if (audioUrl) {
+ await newWaveSurfer.load(audioUrl);
+ }
+ } catch (err) {
+ if (mounted) {
+ console.error("Failed to initialize WaveSurfer:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ if (ws) {
+ const wsToDestroy = ws;
+ wavesurfer.current = null;
+
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ if (mounted) {
+ initWaveSurfer();
+ }
+ });
+ } else {
+ initWaveSurfer();
+ }
+
+ return () => {
+ mounted = false;
+
+ const wsToDestroy = wavesurfer.current;
+
+ wavesurfer.current = null;
+
+ if (wsToDestroy) {
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ });
+ }
+ };
+ }, [audioUrl, audioBuffer, height]);
+
+ if (error) {
+ return (
+ <div
+ className={`flex items-center justify-center ${className}`}
+ style={{ height }}
+ >
+ <span className="text-foreground/60 text-xs">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ 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>
+ );
+}
+
+export default AudioWaveform;
diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/timeline/bookmarks.tsx
new file mode 100644
index 00000000..cfcb1e23
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/bookmarks.tsx
@@ -0,0 +1,111 @@
+import { useEditor } from "@/hooks/use-editor";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getSnappedSeekTime } from "@/lib/time";
+import { Bookmark02Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+interface TimelineBookmarksRowProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineBookmarksRow({
+ zoomLevel,
+ dynamicTimelineWidth,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineBookmarksRowProps) {
+ const editor = useEditor();
+ const activeScene = editor.scenes.getActiveScene();
+
+ return (
+ <div className="relative h-4 flex-1 overflow-hidden">
+ <button
+ className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ aria-label="Timeline ruler"
+ type="button"
+ onWheel={handleWheel}
+ onClick={handleTimelineContentClick}
+ onMouseDown={(event) => {
+ handleRulerMouseDown(event);
+ handleRulerTrackingMouseDown(event);
+ }}
+ >
+ {activeScene.bookmarks.map((time: number) => (
+ <TimelineBookmark
+ key={`bookmark-row-${time}`}
+ time={time}
+ zoomLevel={zoomLevel}
+ />
+ ))}
+ </button>
+ </div>
+ );
+}
+
+export function TimelineBookmark({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+
+ const handleBookmarkActivate = ({
+ event,
+ }: {
+ event:
+ | React.MouseEvent<HTMLButtonElement>
+ | React.KeyboardEvent<HTMLButtonElement>;
+ }) => {
+ event.stopPropagation();
+ const framesPerSecond = activeProject?.settings.fps ?? 30;
+ const snappedTime = getSnappedSeekTime({
+ rawTime: time,
+ duration,
+ fps: framesPerSecond,
+ });
+ editor.playback.seek({ time: snappedTime });
+ };
+
+ return (
+ <button
+ className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ aria-label={`Seek to bookmark at ${time}s`}
+ type="button"
+ onMouseDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onClick={(event) => handleBookmarkActivate({ event })}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleBookmarkActivate({ event });
+ }}
+ >
+ <div className="text-primary absolute top-[-1px] left-[-5px]">
+ <HugeiconsIcon
+ icon={Bookmark02Icon}
+ aria-hidden="true"
+ className="fill-primary size-3"
+ />
+ </div>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx
new file mode 100644
index 00000000..f31c6e28
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/drag-line.tsx
@@ -0,0 +1,28 @@
+import { getDropLineY } from "@/lib/timeline/drop-utils";
+import type { TimelineTrack, DropTarget } from "@/types/timeline";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
+ style={{ top: `${lineTop}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 4a59fd21..7e2b2b66 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,925 +1,530 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Button } from "../../ui/button";
import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Pause,
- Play,
- SkipBack,
- Video,
- Music,
- TypeIcon,
- Magnet,
- Link,
- ZoomIn,
- ZoomOut,
- Bookmark,
- Eye,
- VolumeOff,
- Volume2,
-} from "lucide-react";
+ Delete02Icon,
+ TaskAdd02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../../ui/tooltip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
} from "../../ui/context-menu";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-
-import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
-import { processMediaFiles } from "@/lib/media-processing";
-
-import { toast } from "sonner";
-import { useState, useRef, useEffect, useCallback } from "react";
+import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
+import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
-import {
- TimelinePlayhead,
- useTimelinePlayheadRuler,
-} from "./timeline-playhead";
+import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../selection-box";
-import { useSelectionBox } from "@/hooks/use-selection-box";
-import { SnapIndicator } from "../snap-indicator";
-import { SnapPoint } from "@/hooks/use-timeline-snapping";
-import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
-import { TimelineCacheIndicator } from "./timeline-cache-indicator";
-import { TimelineMarker } from "./timeline-marker";
-import { useFrameCache } from "@/hooks/use-frame-cache";
+import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
- TIMELINE_CONSTANTS,
- snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+ TRACK_ICONS,
} from "@/constants/timeline-constants";
-import { Slider } from "@/components/ui/slider";
-import { formatTimeCode } from "@/lib/time";
-import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ canTracktHaveAudio,
+ canTrackBeHidden,
+ getTimelineZoomMin,
+ getTimelinePaddingPx,
+ isMainTrack,
+} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
+import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
+import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
+import { TimelineRuler } from "./timeline-ruler";
+import { TimelineBookmarksRow } from "./bookmarks";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useEditor } from "@/hooks/use-editor";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { DragLine } from "./drag-line";
+import { invokeAction } from "@/lib/actions";
export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their elements.
- // You can drag media here to add it to your project.
- // elements can be trimmed, deleted, and moved.
-
- const {
- tracks,
- getTotalDuration,
- clearSelectedElements,
- snappingEnabled,
- setSelectedElements,
- toggleTrackMute,
- dragState,
- } = useTimelineStore();
- const { mediaFiles, addMediaFile } = useMediaStore();
- const { activeProject } = useProjectStore();
- const { currentTime, duration, seek, setDuration } = usePlaybackStore();
- const { getRenderStatus } = useFrameCache();
- const [isDragOver, setIsDragOver] = useState(false);
- const { addElementToNewTrack } = useTimelineStore();
- const dragCounterRef = useRef(0);
- const timelineRef = useRef<HTMLDivElement>(null);
- const rulerRef = useRef<HTMLDivElement>(null);
- const [isInTimeline, setIsInTimeline] = useState(false);
-
- // Track mouse down/up for distinguishing clicks from drag/resize ends
- const mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
-
- // Timeline zoom functionality
- const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
- containerRef: timelineRef,
- isInTimeline,
- });
-
- // Old marquee selection removed - using new SelectionBox component instead
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
- (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const tracksScrollRef = useRef<HTMLDivElement>(null);
- const trackLabelsRef = useRef<HTMLDivElement>(null);
- const playheadRef = useRef<HTMLDivElement>(null);
- const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
-
- // Timeline playhead ruler handlers
- const { handleRulerMouseDown } = useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- // Selection box functionality
- const tracksContainerRef = useRef<HTMLDivElement>(null);
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- justFinishedSelecting,
- } = useSelectionBox({
- containerRef: tracksContainerRef,
- playheadRef,
- onSelectionComplete: (elements) => {
- console.log(JSON.stringify({ onSelectionComplete: elements.length }));
- setSelectedElements(elements);
- },
- });
-
- // Calculate snap indicator state
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null
- );
- const showSnapIndicator =
- dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
-
- // Callback to handle snap point changes from TimelineTrackContent
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
-
- // Track mouse down to distinguish real clicks from drag/resize ends
- const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
- // Only track mouse down on timeline background areas (not elements)
- const target = e.target as HTMLElement;
- console.log(
- JSON.stringify({
- debug_mousedown: "START",
- target_class: target.className,
- target_parent_class: target.parentElement?.className,
- clientX: e.clientX,
- clientY: e.clientY,
- timeStamp: e.timeStamp,
- })
- );
-
- const isTimelineBackground =
- !target.closest(".timeline-element") &&
- !playheadRef.current?.contains(target) &&
- !target.closest("[data-track-labels]");
-
- console.log(
- JSON.stringify({
- debug_mousedown: "CHECK",
- isTimelineBackground,
- hasTimelineElement: !!target.closest(".timeline-element"),
- hasPlayhead: !!playheadRef.current?.contains(target),
- hasTrackLabels: !!target.closest("[data-track-labels]"),
- })
- );
-
- if (isTimelineBackground) {
- mouseTrackingRef.current = {
- isMouseDown: true,
- downX: e.clientX,
- downY: e.clientY,
- downTime: e.timeStamp,
- };
- console.log(
- JSON.stringify({
- debug_mousedown: "TRACKED",
- mouseTracking: mouseTrackingRef.current,
- })
- );
- } else {
- console.log(
- JSON.stringify({
- debug_mousedown: "IGNORED - not timeline background",
- })
- );
- }
- }, []);
-
- // Timeline content click to seek handler
- const handleTimelineContentClick = useCallback(
- (e: React.MouseEvent) => {
- console.log(
- JSON.stringify({
- debug_click: "START",
- target: (e.target as HTMLElement).className,
- target_parent: (e.target as HTMLElement).parentElement?.className,
- mouseTracking: mouseTrackingRef.current,
- isSelecting,
- justFinishedSelecting,
- clickX: e.clientX,
- clickY: e.clientY,
- timeStamp: e.timeStamp,
- })
- );
-
- const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
-
- // Reset mouse tracking
- mouseTrackingRef.current = {
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- };
-
- // Only process as click if we tracked a mouse down on timeline background
- if (!isMouseDown) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - no mousedown",
- mouseTracking: mouseTrackingRef.current,
- })
- );
- return;
- }
-
- // Check if mouse moved significantly (indicates drag, not click)
- const deltaX = Math.abs(e.clientX - downX);
- const deltaY = Math.abs(e.clientY - downY);
- const deltaTime = e.timeStamp - downTime;
-
- if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - movement too large",
- deltaX,
- deltaY,
- deltaTime,
- })
- );
- return;
- }
-
- // Don't seek if this was a selection box operation
- if (isSelecting || justFinishedSelecting) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - selection operation",
- isSelecting,
- justFinishedSelecting,
- })
- );
- return;
- }
+ const tracksContainerHeight = { min: 0, max: 800 };
+ const { snappingEnabled } = useTimelineStore();
+ const { clearElementSelection, setElementSelection } = useElementSelection();
+ const editor = useEditor();
+ const timeline = editor.timeline;
+ const tracks = timeline.getTracks();
+ const seek = (time: number) => editor.playback.seek({ time });
- // Don't seek if clicking on timeline elements, but still deselect
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked timeline element",
- })
- );
- return;
- }
+ // refs
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const timelineHeaderRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- // Don't seek if clicking on playhead
- if (playheadRef.current?.contains(e.target as Node)) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked playhead",
- })
- );
- return;
- }
+ // state
+ const [isResizing, setIsResizing] = useState(false);
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null,
+ );
- // Don't seek if clicking on track labels
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked track labels",
- })
- );
- clearSelectedElements();
- return;
- }
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+ const handleResizeStateChange = useCallback(
+ ({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
+ setIsResizing(nextIsResizing);
+ if (!nextIsResizing) {
+ setCurrentSnapPoint(null);
+ }
+ },
+ [],
+ );
- // Clear selected elements when clicking empty timeline area
- console.log(
- JSON.stringify({
- debug_click: "PROCEEDING - clearing elements",
- clearingSelectedElements: true,
- })
- );
- clearSelectedElements();
+ const timelineDuration = timeline.getTotalDuration() || 0;
+ const minZoomLevel = getTimelineZoomMin({
+ duration: timelineDuration,
+ containerWidth: tracksContainerRef.current?.clientWidth,
+ });
- // Determine if we're clicking in ruler or tracks area
- const isRulerClick = (e.target as HTMLElement).closest(
- "[data-ruler-area]"
- );
+ const savedViewState = editor.project.getTimelineViewState();
- console.log(
- JSON.stringify({
- debug_click: "CALCULATING POSITION",
- isRulerClick,
- clientX: e.clientX,
- clientY: e.clientY,
- target_element: (e.target as HTMLElement).tagName,
- target_class: (e.target as HTMLElement).className,
- })
- );
+ const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
+ useTimelineZoom({
+ containerRef: timelineRef,
+ minZoom: minZoomLevel,
+ initialZoom: savedViewState?.zoomLevel,
+ initialScrollLeft: savedViewState?.scrollLeft,
+ initialPlayheadTime: savedViewState?.playheadTime,
+ tracksScrollRef,
+ rulerScrollRef: tracksScrollRef,
+ });
- let mouseX: number;
- let scrollLeft = 0;
+ const {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ } = useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef: timelineHeaderRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
- if (isRulerClick) {
- // Calculate based on ruler position
- const rulerContent = rulerScrollRef.current;
- if (!rulerContent) {
- console.log(
- JSON.stringify({
- debug_click: "ERROR - no ruler container found",
- })
- );
- return;
- }
- const rect = rulerContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = rulerContent.scrollLeft;
- } else {
- const tracksContent = tracksScrollRef.current;
- if (!tracksContent) {
- return;
- }
- const rect = tracksContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = tracksContent.scrollLeft;
- }
+ const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
+ useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- )
- );
+ const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
+ containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
+ zoomLevel,
+ });
- // Use frame snapping for timeline clicking
- const projectFps = activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
- seek(time);
- },
- [
- duration,
- zoomLevel,
- seek,
- rulerScrollRef,
- tracksScrollRef,
- clearSelectedElements,
- isSelecting,
- justFinishedSelecting,
- ]
- );
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ shouldIgnoreClick,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ onSelectionComplete: (elements) => {
+ setElementSelection({ elements });
+ },
+ tracksScrollRef,
+ zoomLevel,
+ });
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
+ const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const contentWidth =
+ timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const paddingPx = getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom: minZoomLevel,
+ });
+ const dynamicTimelineWidth = Math.max(
+ contentWidth + paddingPx,
+ containerWidth,
+ );
- // Old marquee system removed - using new SelectionBox component instead
+ const showSnapIndicator =
+ snappingEnabled &&
+ currentSnapPoint !== null &&
+ (dragState.isDragging || isResizing);
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
+ const {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ } = useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration: timeline.getTotalDuration(),
+ isSelecting,
+ clearSelectedElements: clearElementSelection,
+ seek,
+ });
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
+ useScrollSync({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ });
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
+ const timelineHeaderHeight =
+ timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
- // Don't update state for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
+ return (
+ <section
+ className={
+ "bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
+ }
+ {...dragProps}
+ aria-label="Timeline"
+ >
+ <TimelineToolbar
+ zoomLevel={zoomLevel}
+ minZoom={minZoomLevel}
+ setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
+ />
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
+ <div
+ className="relative flex flex-1 flex-col overflow-hidden"
+ ref={timelineRef}
+ >
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ />
+ <div className="flex flex-1 overflow-hidden">
+ <div className="bg-panel flex w-28 shrink-0 flex-col border-r">
+ <div className="bg-panel flex h-4 items-center justify-between px-3">
+ <span className="opacity-0">.</span>
+ </div>
+ <div className="bg-panel flex h-4 items-center justify-between px-3">
+ <span className="opacity-0">.</span>
+ </div>
+ {tracks.length > 0 && (
+ <div
+ ref={trackLabelsRef}
+ className="bg-panel 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>
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/element
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline element drags - they're handled by track-specific handlers
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- if (hasTimelineElement) {
- return;
- }
-
- const itemData = e.dataTransfer.getData("application/x-media-item");
- if (itemData) {
- try {
- const dragData: DragData = JSON.parse(itemData);
-
- if (dragData.type === "text") {
- // Always create new text track to avoid overlaps
- addElementToNewTrack(dragData);
- } else {
- // Handle media items
- const mediaItem = mediaFiles.find(
- (item: any) => item.id === dragData.id
- );
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- addElementToNewTrack(mediaItem);
- }
- } catch (error) {
- console.error("Error parsing dropped item data:", error);
- toast.error("Failed to add item to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
- for (const processedItem of processedItems) {
- await addMediaFile(activeProject.id, processedItem);
- const currentMediaFiles = mediaFiles;
- const addedItem = currentMediaFiles.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- const trackType: TrackType =
- addedItem.type === "audio" ? "audio" : "media";
- const targetTrackId = useTimelineStore
- .getState()
- .insertTrackAt(trackType, 0);
-
- useTimelineStore.getState().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- }
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- const trackLabelsViewport = trackLabelsScrollRef.current;
-
- if (!rulerViewport || !tracksViewport) return;
-
- // Horizontal scroll synchronization between ruler and tracks
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- rulerViewport.scrollLeft = tracksViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
-
- rulerViewport.addEventListener("scroll", handleRulerScroll);
- tracksViewport.addEventListener("scroll", handleTracksScroll);
-
- // Vertical scroll synchronization between track labels and tracks content
- if (trackLabelsViewport) {
- const handleTrackLabelsScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
- isUpdatingRef.current = false;
- };
- const handleTracksVerticalScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
- isUpdatingRef.current = false;
- };
-
- trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
- tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- trackLabelsViewport.removeEventListener(
- "scroll",
- handleTrackLabelsScroll
- );
- tracksViewport.removeEventListener(
- "scroll",
- handleTracksVerticalScroll
- );
- };
- }
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- };
- }, []);
-
- return (
- <div
- className={
- "h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden"
- }
- {...dragProps}
- onMouseEnter={() => setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- >
- <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} />
-
- {/* Timeline Container */}
- <div
- className="flex-1 flex flex-col overflow-hidden relative"
- ref={timelineRef}
- >
- <TimelinePlayhead
- currentTime={currentTime}
- duration={duration}
- zoomLevel={zoomLevel}
- tracks={tracks}
- seek={seek}
- rulerRef={rulerRef}
- rulerScrollRef={rulerScrollRef}
- tracksScrollRef={tracksScrollRef}
- trackLabelsRef={trackLabelsRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- tracks={tracks}
- timelineRef={timelineRef}
- trackLabelsRef={trackLabelsRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
- />
- {/* Timeline Header with Ruler */}
- <div className="flex bg-panel sticky top-0 z-10">
- {/* Track Labels Header */}
- <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
- {/* Empty space */}
- <span className="text-sm font-medium text-muted-foreground opacity-0">
- .
- </span>
- </div>
-
- {/* Timeline Ruler */}
- <div
- className="flex-1 relative overflow-hidden h-10"
- onWheel={(e) => {
- // Check if this is horizontal scrolling - if so, don't handle it here
- if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- return; // Let ScrollArea handle horizontal scrolling
- }
- handleWheel(e);
- }}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- data-ruler-area
- >
- <ScrollArea
- className="w-full"
- ref={rulerScrollRef}
- onScroll={(e) => {
- if (isUpdatingRef.current) return;
- isUpdatingRef.current = true;
- const tracksViewport = tracksScrollRef.current;
- if (tracksViewport) {
- tracksViewport.scrollLeft = (
- e.currentTarget as HTMLDivElement
- ).scrollLeft;
- }
- isUpdatingRef.current = false;
- }}
- >
- <div
- ref={rulerRef}
- className="relative h-10 select-none cursor-default"
- style={{
- width: `${dynamicTimelineWidth}px`,
- }}
- onMouseDown={handleRulerMouseDown}
- >
- <TimelineCacheIndicator
- duration={duration}
- zoomLevel={zoomLevel}
- tracks={tracks}
- mediaFiles={mediaFiles}
- activeProject={activeProject}
- getRenderStatus={getRenderStatus}
- />
- {/* Time markers */}
- {(() => {
- // Calculate appropriate time interval based on zoom level
- const getTimeInterval = (zoom: number) => {
- const pixelsPerSecond =
- TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
- if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
- if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
- if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
- if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
- if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
- if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
- return 30; // Every 30s when extremely zoomed out
- };
-
- const interval = getTimeInterval(zoomLevel);
- const markerCount = Math.ceil(duration / interval) + 1;
-
- return Array.from({ length: markerCount }, (_, i) => {
- const time = i * interval;
- if (time > duration) return null;
-
- const isMainMarker =
- time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
-
- return (
- <TimelineMarker
- key={i}
- time={time}
- zoomLevel={zoomLevel}
- interval={interval}
- isMainMarker={isMainMarker}
- />
- );
- }).filter(Boolean);
- })()}
-
- {/* Bookmark markers */}
- {(() => {
- const { activeProject } = useProjectStore.getState();
- if (!activeProject?.bookmarks?.length) return null;
-
- return activeProject.bookmarks.map((bookmarkTime, i) => (
- <div
- key={`bookmark-${i}`}
- className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
- style={{
- left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- onClick={(e) => {
- e.stopPropagation();
- usePlaybackStore.getState().seek(bookmarkTime);
- }}
- >
- <div className="absolute top-[-1px] left-[-5px] text-primary">
- <Bookmark className="h-3 w-3 fill-primary" />
- </div>
- </div>
- ));
- })()}
- </div>
- </ScrollArea>
- </div>
- </div>
-
- {/* Tracks Area */}
- <div className="flex-1 flex overflow-hidden">
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel"
- data-track-labels
- >
- <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
- <div className="flex flex-col gap-1">
- {tracks.map((track) => (
- <div
- key={track.id}
- className="flex items-center px-3 group"
- style={{ height: `${getTrackHeight(track.type)}px` }}
- >
- <div className="flex items-center justify-end flex-1 min-w-0 gap-2">
- {track.muted ? (
- <VolumeOff
- className="h-4 w-4 text-destructive cursor-pointer"
- onClick={() => toggleTrackMute(track.id)}
- />
- ) : (
- <Volume2
- className="h-4 w-4 text-muted-foreground cursor-pointer"
- onClick={() => toggleTrackMute(track.id)}
- />
- )}
- <Eye className="h-4 w-4 text-muted-foreground" />
- <TrackIcon track={track} />
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </div>
- )}
-
- {/* Timeline Tracks Content */}
- <div
- className="flex-1 relative overflow-hidden"
- onWheel={(e) => {
- // Check if this is horizontal scrolling - if so, don't handle it here
- if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- return; // Let ScrollArea handle horizontal scrolling
- }
- handleWheel(e);
- }}
- onMouseDown={(e) => {
- handleTimelineMouseDown(e);
- handleSelectionMouseDown(e);
- }}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <ScrollArea
- className="w-full h-full"
- ref={tracksScrollRef}
- onScroll={(e) => {
- if (isUpdatingRef.current) return;
- isUpdatingRef.current = true;
- const rulerViewport = rulerScrollRef.current;
- if (rulerViewport) {
- rulerViewport.scrollLeft = (
- e.currentTarget as HTMLDivElement
- ).scrollLeft;
- }
- isUpdatingRef.current = false;
- }}
- >
- <div
- className="relative flex-1"
- style={{
- height: `${Math.max(
- 200,
- Math.min(800, getTotalTracksHeight(tracks))
- )}px`,
- width: `${dynamicTimelineWidth}px`,
- }}
- >
- {tracks.length === 0 ? (
- <div />
- ) : (
- <>
- {tracks.map((track, index) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className="absolute left-0 right-0"
- style={{
- top: `${getCumulativeHeightBefore(
- tracks,
- index
- )}px`,
- height: `${getTrackHeight(track.type)}px`,
- }}
- onClick={(e) => {
- // If clicking empty area (not on a element), deselect all elements
- if (
- !(e.target as HTMLElement).closest(
- ".timeline-element"
- )
- ) {
- clearSelectedElements();
- }
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- onSnapPointChange={handleSnapPointChange}
- rulerScrollRef={rulerScrollRef}
- tracksScrollRef={tracksScrollRef}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="z-200">
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- toggleTrackMute(track.id);
- }}
- >
- {track.muted ? "Unmute Track" : "Mute Track"}
- </ContextMenuItem>
- <ContextMenuItem onClick={(e) => e.stopPropagation()}>
- Track settings (soon)
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
- </div>
- );
+ <div
+ className="relative 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}
+ 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="relative"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ >
+ <div
+ ref={timelineHeaderRef}
+ className="bg-panel sticky top-0 z-30 flex flex-col"
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ </div>
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ rulerRef={rulerRef}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <div
+ className="relative"
+ style={{
+ height: `${Math.max(
+ tracksContainerHeight.min,
+ Math.min(
+ tracksContainerHeight.max,
+ getTotalTracksHeight({ tracks }),
+ ),
+ )}px`,
+ }}
+ >
+ {tracks.length === 0 ? (
+ <div />
+ ) : (
+ tracks.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={handleSelectionMouseDown}
+ shouldIgnoreClick={shouldIgnoreClick}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="z-200 w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(e) => {
+ e.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ <span>
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={ViewIcon} />
+ <span>
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(e) => {
+ e.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
+ variant="destructive"
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ Delete track
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))
+ )}
+ </div>
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </section>
+ );
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return (
- <>
- {track.type === "media" && (
- <Video className="w-4 h-4 shrink-0 text-muted-foreground" />
- )}
- {track.type === "text" && (
- <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
- )}
- {track.type === "audio" && (
- <Music className="w-4 h-4 shrink-0 text-muted-foreground" />
- )}
- </>
- );
+ return <>{TRACK_ICONS[track.type]}</>;
+}
+
+function TrackToggleIcon({
+ isOff,
+ icons,
+ onClick,
+}: {
+ isOff: boolean;
+ icons: {
+ on: IconSvgElement;
+ off: IconSvgElement;
+ };
+ onClick: () => void;
+}) {
+ return (
+ <>
+ {isOff ? (
+ <HugeiconsIcon
+ icon={icons.off}
+ className="text-destructive size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={icons.on}
+ className="text-muted-foreground size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ )}
+ </>
+ );
}
diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/timeline/snap-indicator.tsx
new file mode 100644
index 00000000..6bfdf086
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/snap-indicator.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute z-90"
+ style={{
+ left: `${leftPosition}px`,
+ top: topPosition,
+ height: `${height}px`,
+ width: "2px",
+ }}
+ >
+ <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
deleted file mode 100644
index 7def2985..00000000
--- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { TimelineTrack } from "@/types/timeline";
-import { MediaFile } from "@/types/media";
-import { TProject } from "@/types/project";
-import { useSceneStore } from "@/stores/scene-store";
-
-interface CacheSegment {
- startTime: number;
- endTime: number;
- cached: boolean;
-}
-
-interface TimelineCacheIndicatorProps {
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- mediaFiles: MediaFile[];
- activeProject: TProject | null;
- getRenderStatus: (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ) => "cached" | "not-cached";
-}
-
-export function TimelineCacheIndicator({
- duration,
- zoomLevel,
- tracks,
- mediaFiles,
- activeProject,
- getRenderStatus,
-}: TimelineCacheIndicatorProps) {
- const { currentScene } = useSceneStore();
-
- // Calculate cache segments by sampling the timeline
- const calculateCacheSegments = (): CacheSegment[] => {
- const segments: CacheSegment[] = [];
- const sampleRate = 10; // Sample every 0.1 seconds
- const totalSamples = Math.ceil(duration * sampleRate);
-
- if (totalSamples === 0) {
- return [{ startTime: 0, endTime: duration, cached: false }];
- }
-
- let currentSegment: CacheSegment | null = null;
-
- for (let i = 0; i <= totalSamples; i++) {
- const time = i / sampleRate;
- const cached =
- getRenderStatus(
- time,
- tracks,
- mediaFiles,
- activeProject,
- currentScene?.id
- ) === "cached";
-
- if (!currentSegment) {
- // Start first segment
- currentSegment = {
- startTime: time,
- endTime: time,
- cached,
- };
- } else if (currentSegment.cached === cached) {
- // Extend current segment
- currentSegment.endTime = time;
- } else {
- // Finish current segment and start new one
- segments.push(currentSegment);
- currentSegment = {
- startTime: time,
- endTime: time,
- cached,
- };
- }
- }
-
- // Add the last segment
- if (currentSegment) {
- currentSegment.endTime = duration;
- segments.push(currentSegment);
- }
-
- return segments;
- };
-
- const cacheSegments = calculateCacheSegments();
-
- return (
- <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none">
- {cacheSegments.map((segment, index) => {
- const startX =
- segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const endX =
- segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const width = endX - startX;
-
- return (
- <div
- key={index}
- className={cn(
- "absolute top-0 h-px",
- segment.cached ? "bg-primary" : "bg-transparent"
- )}
- style={{
- left: `${startX}px`,
- width: `${width}px`,
- }}
- title={
- segment.cached
- ? "Cached (fast playback)"
- : "Not cached (will render)"
- }
- />
- );
- })}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index ca47d3ca..fd292ad8 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -1,387 +1,554 @@
"use client";
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import AudioWaveform from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
- Scissors,
- Trash2,
- Copy,
- Search,
- RefreshCw,
- EyeOff,
- Eye,
- Volume2,
- VolumeX,
-} from "lucide-react";
-import { useMediaStore } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import AudioWaveform from "../audio-waveform";
-import { toast } from "sonner";
-import { TimelineElementProps, MediaElement } from "@/types/timeline";
-import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
+ getTrackClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasMediaId,
+} from "@/lib/timeline";
import {
- getTrackElementClasses,
- TIMELINE_CONSTANTS,
- getTrackHeight,
-} from "@/constants/timeline-constants";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
} from "../../ui/context-menu";
-import { useMediaPanelStore } from "../media-panel/store";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps } from "react";
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onElementMouseDown,
- onElementClick,
-}: TimelineElementProps) {
- const { mediaFiles } = useMediaStore();
- const {
- dragState,
- copySelected,
- selectedElements,
- deleteSelected,
- splitSelected,
- toggleSelectedHidden,
- toggleSelectedMuted,
- duplicateElement,
- revealElementInMedia,
- replaceElementWithFile,
- getContextMenuState,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
+function getDisplayShortcut(action: TAction) {
+ const { defaultShortcuts } = getActionDefinition(action);
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
- const mediaItem =
- element.type === "media"
- ? mediaFiles.find((file) => file.id === element.mediaId)
- : null;
- const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
- const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
- useTimelineElementResize({
- element,
- track,
- zoomLevel,
- });
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ dragState: ElementDragState;
+}
- const {
- isMultipleSelected,
- isCurrentElementSelected,
- hasAudioElements,
- canSplitSelected,
- } = getContextMenuState(track.id, element.id);
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+}: TimelineElementProps) {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+ const { requestRevealMedia } = useAssetsPanelStore();
- const effectiveDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementWidth = Math.max(
- TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
- effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
- );
+ const mediaAssets = editor.media.getAssets();
+ let mediaAsset: MediaAsset | null = null;
- const isBeingDragged = dragState.elementId === element.id;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : element.startTime;
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
- const elementLeft = elementStartTime * 50 * zoomLevel;
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
- const handleElementSplitContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- splitSelected(
- currentTime,
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
- const handleElementDuplicateContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- duplicateElement(track.id, element.id);
- };
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
- const handleElementCopyContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- copySelected();
- };
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth =
+ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const elementLeft = displayedStartTime * 50 * zoomLevel;
- const handleElementDeleteContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- deleteSelected(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
- const handleToggleElementContext = (e: React.MouseEvent) => {
- e.stopPropagation();
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
- if (hasAudio && element.type === "media") {
- toggleSelectedMuted(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- } else {
- toggleSelectedHidden(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- }
- };
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className={`absolute top-0 h-full select-none ${
+ isBeingDragged ? "z-30" : "z-10"
+ }`}
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ isBeingDragged={isBeingDragged}
+ hasAudio={hasAudio}
+ isMuted={isMuted}
+ mediaAssets={mediaAssets}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="z-200 w-64">
+ <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event) => handleRevealInMedia({ event })}
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
- const handleReplaceClip = (e: React.MouseEvent) => {
- e.stopPropagation();
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ isBeingDragged,
+ hasAudio,
+ isMuted,
+ mediaAssets,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isBeingDragged: boolean;
+ hasAudio: boolean;
+ isMuted: boolean;
+ mediaAssets: MediaAsset[];
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ return (
+ <div
+ className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
+ {
+ type: track.type,
+ },
+ )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ >
+ <button
+ type="button"
+ className="absolute inset-0 size-full cursor-pointer"
+ onClick={(e) => onElementClick(e, element)}
+ onMouseDown={(e) => onElementMouseDown(e, element)}
+ >
+ <div className="absolute inset-0 flex h-full items-center">
+ <ElementContent
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ mediaAssets={mediaAssets}
+ />
+ </div>
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "video/*,audio/*,image/*";
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0];
- if (file) {
- await replaceElementWithFile(track.id, element.id, file);
- }
- };
- input.click();
- };
+ {(hasAudio
+ ? isMuted
+ : canElementBeHidden(element) && element.hidden) && (
+ <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
+ {hasAudio ? (
+ <HugeiconsIcon
+ icon={VolumeHighIcon}
+ className="size-6 text-white"
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={VolumeOffIcon}
+ className="size-6 text-white"
+ />
+ )}
+ </div>
+ )}
+ </button>
- const handleRevealInMedia = (e: React.MouseEvent) => {
- e.stopPropagation();
- revealElementInMedia(element.id);
- };
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ >
+ <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
+ </button>
+ );
+}
- const renderElementContent = () => {
- if (element.type === "text") {
- return (
- <div className="w-full h-full flex items-center justify-start pl-2">
- <span className="text-xs text-white truncate">{element.content}</span>
- </div>
- );
- }
+function ElementContent({
+ element,
+ track,
+ isSelected,
+ mediaAssets,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ mediaAssets: MediaAsset[];
+}) {
+ if (element.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>
+ );
+ }
- const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- }
+ if (element.type === "sticker") {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
+ alt={element.name}
+ className="size-5 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+ }
- if (
- mediaItem.type === "image" ||
- (mediaItem.type === "video" && mediaItem.thumbnailUrl)
- ) {
- const trackHeight = getTrackHeight(track.type);
- const tileWidth = trackHeight * (16 / 9);
+ if (element.type === "audio") {
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
- const imageUrl =
- mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
+ const audioUrl =
+ element.sourceType === "library"
+ ? element.sourceUrl
+ : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
- return (
- <div className="w-full h-full flex items-center justify-center">
- <div
- className={`w-full h-full relative ${
- isSelected ? "bg-primary" : "bg-transparent"
- }`}
- >
- <div
- className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`}
- style={{
- backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
- />
- </div>
- </div>
- );
- }
+ 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>
+ );
+ }
- if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url || ""}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- };
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ if (!mediaAsset) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
- const handleElementMouseDown = (e: React.MouseEvent) => {
- if (onElementMouseDown) {
- onElementMouseDown(e, element);
- }
- };
+ if (
+ mediaAsset.type === "image" ||
+ (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
+ ) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+ const imageUrl =
+ mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
- const isMuted = element.type === "media" && element.muted;
+ return (
+ <div className="flex size-full items-center justify-center">
+ <div
+ className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
+ >
+ <div
+ className="absolute right-0 left-0"
+ style={{
+ backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ top: isSelected ? "0.25rem" : "0rem",
+ bottom: isSelected ? "0.25rem" : "0rem",
+ }}
+ />
+ </div>
+ </div>
+ );
+ }
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className={`absolute top-0 h-full select-none timeline-element ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- }}
- data-element-id={element.id}
- data-track-id={track.id}
- onMouseMove={resizing ? handleResizeMove : undefined}
- onMouseUp={resizing ? handleResizeEnd : undefined}
- onMouseLeave={resizing ? handleResizeEnd : undefined}
- >
- <div
- className={`relative h-full rounded-[0.5rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
- track.type
- )} ${isSelected ? "" : ""} ${
- isBeingDragged ? "z-50" : "z-10"
- } ${element.hidden ? "opacity-50" : ""}`}
- onClick={(e) => onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
- <div className="absolute inset-0 flex items-center h-full">
- {renderElementContent()}
- </div>
+ return (
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ );
+}
- {(hasAudio ? isMuted : element.hidden) && (
- <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
- {hasAudio ? (
- <VolumeX className="h-6 w-6 text-white" />
- ) : (
- <EyeOff className="h-6 w-6 text-white" />
- )}
- </div>
- )}
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
+ Copy
+ </ActionMenuItem>
+ );
+}
- {isSelected && (
- <>
- <div
- className="absolute left-0 top-0 bottom-0 w-[0.6rem] cursor-w-resize bg-primary z-50 flex items-center justify-center"
- onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
- >
- <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" />
- </div>
- <div
- className="absolute right-0 top-0 bottom-0 w-[0.6rem] cursor-e-resize bg-primary z-50 flex items-center justify-center"
- onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
- >
- <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" />
- </div>
- </>
- )}
- </div>
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="z-200">
- {(!isMultipleSelected ||
- (isMultipleSelected &&
- isCurrentElementSelected &&
- canSplitSelected)) && (
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- {isMultipleSelected && isCurrentElementSelected
- ? `Split ${selectedElements.length} elements at playhead`
- : "Split at playhead"}
- </ContextMenuItem>
- )}
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ );
+ };
- <ContextMenuItem onClick={handleElementCopyContext}>
- <Copy className="h-4 w-4 mr-2" />
- {isMultipleSelected && isCurrentElementSelected
- ? `Copy ${selectedElements.length} elements`
- : "Copy element"}
- </ContextMenuItem>
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
- <ContextMenuItem onClick={handleToggleElementContext}>
- {isMultipleSelected && isCurrentElementSelected ? (
- hasAudioElements ? (
- <VolumeX className="h-4 w-4 mr-2" />
- ) : (
- <EyeOff className="h-4 w-4 mr-2" />
- )
- ) : hasAudio ? (
- isMuted ? (
- <Volume2 className="h-4 w-4 mr-2" />
- ) : (
- <VolumeX className="h-4 w-4 mr-2" />
- )
- ) : element.hidden ? (
- <Eye className="h-4 w-4 mr-2" />
- ) : (
- <EyeOff className="h-4 w-4 mr-2" />
- )}
- <span>
- {isMultipleSelected && isCurrentElementSelected
- ? hasAudioElements
- ? `Toggle mute ${selectedElements.length} elements`
- : `Toggle visibility ${selectedElements.length} elements`
- : hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
- </span>
- </ContextMenuItem>
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
- {!isMultipleSelected && (
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- )}
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
- {!isMultipleSelected && element.type === "media" && (
- <>
- <ContextMenuItem onClick={handleRevealInMedia}>
- <Search className="h-4 w-4 mr-2" />
- Reveal in media
- </ContextMenuItem>
- <ContextMenuItem onClick={handleReplaceClip}>
- <RefreshCw className="h-4 w-4 mr-2" />
- Replace clip
- </ContextMenuItem>
- </>
- )}
+ return (
+ <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
- <ContextMenuSeparator />
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
- <ContextMenuItem
- onClick={handleElementDeleteContext}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- {isMultipleSelected && isCurrentElementSelected
- ? `Delete ${selectedElements.length} elements`
- : `Delete ${element.type === "text" ? "text" : "clip"}`}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- );
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TAction;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut(action)}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
}
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
deleted file mode 100644
index 1351a457..00000000
--- a/apps/web/src/components/editor/timeline/timeline-marker.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-
-interface TimelineMarkerProps {
- time: number;
- zoomLevel: number;
- interval: number;
- isMainMarker: boolean;
-}
-
-export function TimelineMarker({
- time,
- zoomLevel,
- interval,
- isMainMarker,
-}: TimelineMarkerProps) {
- return (
- <div
- className={cn(
- "absolute top-0 h-4",
- isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- )}
- style={{
- left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- >
- <span
- className={cn(
- "absolute top-1 left-1 text-[0.6rem]",
- isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- )}
- >
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes
- .toString()
- .padStart(2, "0")}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
- </span>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index a1be5b26..58d0adb0 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -1,172 +1,92 @@
"use client";
-import { useRef, useState, useEffect } from "react";
-import { TimelineTrack } from "@/types/timeline";
+import { useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { useEditor } from "@/hooks/use-editor";
interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- trackLabelsRef?: React.RefObject<HTMLDivElement>;
- timelineRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
- isSnappingToPlayhead?: boolean;
+ zoomLevel: number;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const [scrollLeft, setScrollLeft] = useState(0);
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
- // Track scroll position to lock playhead to frame
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
+ const timelineContainerHeight =
+ tracksScrollRef.current?.clientHeight ??
+ timelineRef.current?.clientHeight ??
+ 400;
+ const totalHeight = Math.max(0, timelineContainerHeight - 4);
- if (!tracksViewport) return;
+ const timelinePosition =
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelinePosition;
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
+ const handlePlayheadKeyDown = (
+ event: React.KeyboardEvent<HTMLDivElement>,
+ ) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
- // Set initial scroll position
- setScrollLeft(tracksViewport.scrollLeft);
+ 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),
+ );
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
+ editor.playback.seek({ time: nextTime });
+ };
- // Use timeline container height minus a few pixels for breathing room
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 4;
+ return (
+ <div
+ ref={playheadRef}
+ role="slider"
+ aria-label="Timeline playhead"
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ aria-valuenow={playheadPosition}
+ tabIndex={0}
+ className="pointer-events-auto absolute z-60"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px",
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ onKeyDown={handlePlayheadKeyDown}
+ >
+ <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
- // Get dynamic track labels width, fallback to 0 if no tracks or no ref
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
-
- // Calculate position locked to timeline content (accounting for scroll)
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
-
- // Get the timeline content width and viewport width for right boundary
- const timelineContentWidth =
- duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const tracksViewport = tracksScrollRef.current;
- const viewportWidth = tracksViewport?.clientWidth || 1000;
-
- // Constrain playhead to never appear outside the timeline area
- const leftBoundary = trackLabelsWidth;
- const rightBoundary = Math.min(
- trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
- trackLabelsWidth + viewportWidth // Don't go beyond viewport
- );
-
- const leftPosition = Math.max(
- leftBoundary,
- Math.min(rightBoundary, rawLeftPosition)
- );
-
- // Debug logging when playhead might go outside
- if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
- console.log(
- "PLAYHEAD VISUAL DEBUG:",
- JSON.stringify({
- playheadPosition,
- timelinePosition,
- trackLabelsWidth,
- scrollLeft,
- rawLeftPosition,
- constrainedLeftPosition: leftPosition,
- leftBoundary,
- rightBoundary,
- timelineContentWidth,
- viewportWidth,
- zoomLevel,
- })
- );
- }
-
- return (
- <div
- ref={playheadRef}
- className="absolute pointer-events-auto z-40"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: "2px", // Slightly wider for better click target
- }}
- onMouseDown={handlePlayheadMouseDown}
- >
- {/* The playhead line spanning full height */}
- <div
- className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
- />
-
- {/* Playhead dot indicator at the top (in ruler area) */}
- <div
- className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
- />
- </div>
- );
+ <div
+ className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ />
+ </div>
+ );
}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- return { handleRulerMouseDown, isDraggingRuler };
-}
-
-export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
new file mode 100644
index 00000000..87702a4f
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
@@ -0,0 +1,83 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_FPS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
+import { TimelineTick } from "./timeline-tick";
+
+interface TimelineRulerProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineRuler({
+ zoomLevel,
+ dynamicTimelineWidth,
+ rulerRef,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineRulerProps) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
+ const effectiveDuration = Math.max(duration, visibleDuration);
+ const project = editor.project.getActive();
+ const fps = project?.settings.fps ?? DEFAULT_FPS;
+ const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
+ zoomLevel,
+ fps,
+ });
+ const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
+
+ const timelineTicks: Array<JSX.Element> = [];
+ for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
+ const time = tickIndex * tickIntervalSeconds;
+ if (time > effectiveDuration) break;
+
+ const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
+ timelineTicks.push(
+ <TimelineTick
+ key={tickIndex}
+ time={time}
+ zoomLevel={zoomLevel}
+ fps={fps}
+ showLabel={showLabel}
+ />,
+ );
+ }
+
+ return (
+ <div
+ role="slider"
+ tabIndex={0}
+ aria-label="Timeline ruler"
+ aria-valuemin={0}
+ aria-valuemax={effectiveDuration}
+ aria-valuenow={0}
+ className="relative h-4 flex-1 overflow-x-visible"
+ onWheel={handleWheel}
+ onClick={handleTimelineContentClick}
+ onMouseDown={handleRulerTrackingMouseDown}
+ onKeyDown={() => {}}
+ >
+ <div
+ role="none"
+ ref={rulerRef}
+ className="relative h-4 cursor-default select-none"
+ style={{
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {timelineTicks}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/timeline/timeline-tick.tsx
new file mode 100644
index 00000000..9402d25e
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-tick.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ zoomLevel: number;
+ fps: number;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds: time, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index 80c6ce08..822bb438 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -1,397 +1,355 @@
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useSceneStore } from "@/stores/scene-store";
-import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
+import { SkipBack, SplitSquareHorizontal } from "lucide-react";
import {
- Pause,
- Play,
- SkipBack,
- Bookmark,
- Magnet,
- Link,
- ZoomOut,
- ZoomIn,
- Copy,
- Trash2,
- Snowflake,
- ArrowLeftToLine,
- ArrowRightToLine,
- SplitSquareHorizontal,
- Scissors,
- LayersIcon,
-} from "lucide-react";
-import {
- SplitButton,
- SplitButtonLeft,
- SplitButtonRight,
- SplitButtonSeparator,
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
-import { DEFAULT_FPS } from "@/stores/project-store";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view";
+import { type TAction, invokeAction } from "@/lib/actions";
+import { cn } from "@/utils/ui";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Bookmark02Icon,
+ Delete02Icon,
+ SnowIcon,
+ ScissorIcon,
+ MagnetIcon,
+ Link04Icon,
+ SearchAddIcon,
+ SearchMinusIcon,
+ PauseIcon,
+ PlayIcon,
+ Copy01Icon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ Layers01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
export function TimelineToolbar({
- zoomLevel,
- setZoomLevel,
+ zoomLevel,
+ minZoom,
+ setZoomLevel,
}: {
- zoomLevel: number;
- setZoomLevel: (zoom: number) => void;
+ zoomLevel: number;
+ minZoom: number;
+ setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
- const {
- tracks,
- addTrack,
- addElementToTrack,
- selectedElements,
- clearSelectedElements,
- deleteSelected,
- splitSelected,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
- } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
- const { scenes, currentScene } = useSceneStore();
+ const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
+ const newZoomLevel =
+ direction === "in"
+ ? Math.min(
+ TIMELINE_CONSTANTS.ZOOM_MAX,
+ zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
+ )
+ : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
+ setZoomLevel({ zoom: newZoomLevel });
+ };
+
+ return (
+ <ScrollArea className="scrollbar-hidden">
+ <div className="flex h-10 items-center justify-between border-b px-2 py-1">
+ <ToolbarLeftSection />
+
+ <SceneSelector />
+
+ <ToolbarRightSection
+ zoomLevel={zoomLevel}
+ minZoom={minZoom}
+ onZoomChange={(zoom) => setZoomLevel({ zoom })}
+ onZoom={handleZoom}
+ />
+ </div>
+ </ScrollArea>
+ );
+}
+
+function ToolbarLeftSection() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const isPlaying = editor.playback.getIsPlaying();
+ const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+
+ const handleAction = ({
+ action,
+ event,
+ }: {
+ action: TAction;
+ event: React.MouseEvent;
+ }) => {
+ event.stopPropagation();
+ invokeAction(action);
+ };
+
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={
+ isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} />
+ )
+ }
+ tooltip={isPlaying ? "Pause" : "Play"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-play", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={<SkipBack />}
+ tooltip="Go to start"
+ onClick={({ event }) => handleAction({ action: "goto-start", event })}
+ />
+
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <TimeDisplay />
- const handleSplitSelected = () => {
- splitSelected(currentTime);
- };
+ <div className="bg-border mx-1 h-6 w-px" />
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) return;
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ tooltip="Split element"
+ onClick={({ event }) =>
+ handleAction({ action: "split", event })
+ }
+ />
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => el.id === elementId);
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- });
- clearSelectedElements();
- };
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignLeftIcon} />}
+ tooltip="Split left"
+ onClick={({ event }) =>
+ handleAction({ action: "split-left", event })
+ }
+ />
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignRightIcon} />}
+ tooltip="Split right"
+ onClick={({ event }) =>
+ handleAction({ action: "split-right", event })
+ }
+ />
- const handleSplitAndKeepLeft = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepLeft(trackId, elementId, currentTime);
- };
+ <ToolbarButton
+ icon={<SplitSquareHorizontal />}
+ tooltip="Coming soon" /* separate audio */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
- const handleSplitAndKeepRight = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepRight(trackId, elementId, currentTime);
- };
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ tooltip="Duplicate element"
+ onClick={({ event }) =>
+ handleAction({ action: "duplicate-selected", event })
+ }
+ />
- const handleSeparateAudio = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
- separateAudio(trackId, elementId);
- };
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={SnowIcon} />}
+ tooltip="Coming soon" /* freeze frame */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
- const handleDeleteSelected = () => {
- deleteSelected();
- };
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ tooltip="Delete element"
+ onClick={({ event }) =>
+ handleAction({ action: "delete-selected", event })
+ }
+ />
- const handleZoomIn = () => {
- setZoomLevel(Math.min(4, zoomLevel + 0.25));
- };
+ <div className="bg-border mx-1 h-6 w-px" />
- const handleZoomOut = () => {
- setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
- };
+ <Tooltip>
+ <ToolbarButton
+ icon={
+ <HugeiconsIcon
+ icon={Bookmark02Icon}
+ className={currentBookmarked ? "fill-primary text-primary" : ""}
+ />
+ }
+ tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-bookmark", event })
+ }
+ />
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
+
+function TimeDisplay() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const totalDuration = editor.timeline.getTotalDuration();
+ const fps = editor.project.getActive().settings.fps;
+
+ return (
+ <div className="flex flex-row items-center justify-center px-2">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <div className="text-muted-foreground px-2 font-mono text-xs">/</div>
+ <div className="text-muted-foreground text-center font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </div>
+ </div>
+ );
+}
+
+function SceneSelector() {
+ const editor = useEditor();
+ const currentScene = editor.scenes.getActiveScene();
+
+ return (
+ <div>
+ <SplitButton className="border-foreground/10 border">
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
+ <SplitButtonSeparator />
+ <ScenesView>
+ <SplitButtonRight onClick={() => {}} type="button">
+ <HugeiconsIcon icon={Layers01Icon} className="size-4" />
+ </SplitButtonRight>
+ </ScenesView>
+ </SplitButton>
+ </div>
+ );
+}
- const handleZoomSliderChange = (values: number[]) => {
- setZoomLevel(values[0]);
- };
+function ToolbarRightSection({
+ zoomLevel,
+ minZoom,
+ onZoomChange,
+ onZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ onZoomChange: (zoom: number) => void;
+ onZoom: (options: { direction: "in" | "out" }) => void;
+}) {
+ const {
+ snappingEnabled,
+ rippleEditingEnabled,
+ toggleSnapping,
+ toggleRippleEditing,
+ } = useTimelineStore();
- const handleToggleBookmark = async () => {
- await toggleBookmark(currentTime);
- };
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={
+ <HugeiconsIcon
+ icon={MagnetIcon}
+ className={cn(snappingEnabled ? "text-primary" : "")}
+ />
+ }
+ tooltip="Auto snapping"
+ onClick={() => toggleSnapping()}
+ />
- const currentBookmarked = isBookmarked(currentTime);
- return (
- <div className="flex items-center justify-between px-2 py-1 border-b h-10">
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggle}>
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={() => seek(0)}>
- <SkipBack className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Return to Start (Home / Enter)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div className="flex flex-row items-center justify-center px-2">
- <EditableTimecode
- time={currentTime}
- duration={duration}
- format="HH:MM:SS:FF"
- fps={activeProject?.fps ?? DEFAULT_FPS}
- onTimeChange={seek}
- className="text-center"
- />
- <div className="text-xs text-muted-foreground font-mono px-2">
- /
- </div>
- <div className="text-xs text-muted-foreground font-mono text-center">
- {formatTimeCode(duration, "HH:MM:SS:FF")}
- </div>
- </div>
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleToggleBookmark}>
- <Bookmark
- className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div>
- <SplitButton className="border border-foreground/10">
- <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
- <SplitButtonSeparator />
- <ScenesView>
- <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
- <LayersIcon />
- </SplitButtonRight>
- </ScenesView>
- </SplitButton>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <Magnet className="h-4 w-4 text-primary" />
- ) : (
- <Magnet className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleRippleEditing}>
- <Link
- className={`h-4 w-4 ${
- rippleEditingEnabled ? "text-primary" : ""
- }`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
+ <ToolbarButton
+ icon={
+ <HugeiconsIcon
+ icon={Link04Icon}
+ className={cn(
+ rippleEditingEnabled ? "text-primary" : "",
+ "scale-110",
+ )}
+ />
+ }
+ tooltip="Ripple editing"
+ onClick={() => toggleRippleEditing()}
+ />
+ </TooltipProvider>
- <div className="h-6 w-px bg-border mx-1" />
- <div className="flex items-center gap-1">
- <Button variant="text" size="icon" onClick={handleZoomOut}>
- <ZoomOut className="h-4 w-4" />
- </Button>
- <Slider
- className="w-24"
- value={[zoomLevel]}
- onValueChange={handleZoomSliderChange}
- min={0.25}
- max={4}
- step={0.25}
- />
- <Button variant="text" size="icon" onClick={handleZoomIn}>
- <ZoomIn className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- );
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <div className="flex items-center gap-1">
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={() => onZoom({ direction: "out" })}
+ >
+ <HugeiconsIcon icon={SearchAddIcon} />
+ </Button>
+ <Slider
+ className="w-24"
+ value={[zoomLevel]}
+ onValueChange={(values) => onZoomChange(values[0])}
+ min={minZoom}
+ max={TIMELINE_CONSTANTS.ZOOM_MAX}
+ step={TIMELINE_CONSTANTS.ZOOM_STEP}
+ />
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={() => onZoom({ direction: "in" })}
+ >
+ <HugeiconsIcon icon={SearchMinusIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function ToolbarButton({
+ icon,
+ tooltip,
+ onClick,
+ disabled,
+}: {
+ icon: React.ReactNode;
+ tooltip: string;
+ onClick: ({ event }: { event: React.MouseEvent }) => void;
+ disabled?: boolean;
+}) {
+ return (
+ <Tooltip delayDuration={200}>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ type="button"
+ onClick={(event) => onClick({ event })}
+ className={disabled ? "cursor-not-allowed opacity-50" : ""}
+ >
+ {icon}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>{tooltip}</TooltipContent>
+ </Tooltip>
+ );
}
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 7cfde931..29d58315 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -1,1199 +1,113 @@
"use client";
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { processMediaFiles } from "@/lib/media-processing";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
- TrackType,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
-import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
+import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement as TimelineElementType } from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/types/timeline";
+import { useEditor } from "@/hooks/use-editor";
+import { cn } from "@/utils/ui";
-export function TimelineTrackContent({
- track,
- zoomLevel,
- onSnapPointChange,
- rulerScrollRef,
- tracksScrollRef,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
-}) {
- const { mediaFiles } = useMediaStore();
- const {
- tracks,
- addTrack,
- moveElementToTrack,
- updateElementStartTime,
- updateElementStartTimeWithRipple,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- rippleEditingEnabled,
- } = useTimelineStore();
-
- const { currentTime, duration } = usePlaybackStore();
-
- // Initialize snapping hook
- const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
- snapThreshold: 10,
- enableElementSnapping: snappingEnabled,
- enablePlayheadSnapping: snappingEnabled,
- });
-
- // Helper function for drop snapping that tries both edges
- const getDropSnappedTime = (
- dropTime: number,
- elementDuration: number,
- excludeElementId?: string
- ) => {
- // Always apply frame snapping first
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- let finalTime = snapTimeToFrame(dropTime, projectFps);
-
- // Additionally apply element snapping if enabled
- if (snappingEnabled) {
- // Try snapping both start and end edges for drops
- const startSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- // Only use element snapping if it found a snap point, otherwise keep frame-snapped time
- if (bestSnapResult.snapPoint) {
- finalTime = bestSnapResult.snappedTime;
- }
- }
-
- return finalTime;
- };
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- const lastMouseXRef = useRef(0);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
- lastMouseXRef.current = e.clientX;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Always apply frame snapping first
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- let finalTime = snapTimeToFrame(adjustedTime, projectFps);
- let snapPoint = null;
-
- // Additionally apply element snapping if enabled
- if (snappingEnabled) {
- // Find the element being dragged to get its duration
- let elementDuration = 5; // fallback duration
- if (dragState.elementId && dragState.trackId) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const element = sourceTrack?.elements.find(
- (e) => e.id === dragState.elementId
- );
- if (element) {
- elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- }
- }
-
- // Try snapping both start and end edges
- const startSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- // Only use element snapping if it found a snap point, otherwise keep frame-snapped time
- if (bestSnapResult.snapPoint) {
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.snapPoint;
- }
-
- // Notify parent component about snap point change
- onSnapPointChange?.(snapPoint);
- } else {
- // Clear snap point when element snapping is disabled
- onSnapPointChange?.(null);
- }
-
- updateDragTime(finalTime);
- };
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- }
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- return;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- finalTime
- );
- }
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId!,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- }
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- }
- }
- }
- }
-
- if (isTrackThatStartedDrag) {
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
- });
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // If element is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline elements and media items
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- // Text elements have default duration of 5 seconds
- const newElementDuration = 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return snappedTime < existingEnd && newElementEnd > existingStart;
- });
- } else {
- // Media elements
- const mediaItem = mediaFiles.find(
- (item) => item.id === dragData.id
- );
- if (mediaItem) {
- const newElementDuration = mediaItem.duration || 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && newElementEnd > existingStart
- );
- });
- }
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineElement) {
- try {
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (timelineElementData) {
- const { elementId, trackId: fromTrackId } =
- JSON.parse(timelineElementData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: any) => c.id === elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const snappedTime = getDropSnappedTime(
- dropTime,
- movingElementDuration,
- elementId
- );
- const movingElementEnd = snappedTime + movingElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && movingElementEnd > existingStart
- );
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
- setWouldOverlap(false);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Debug logging
- console.log(
- JSON.stringify({
- message: "Drop event started in timeline track",
- dataTransferTypes: Array.from(e.dataTransfer.types),
- trackId: track.id,
- trackType: track.type,
- })
- );
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
- const hasFiles = e.dataTransfer.files?.length > 0;
-
- if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const mouseY = e.clientY - rect.top; // Get Y position relative to this track
- const newStartTime =
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- const snappedTime = snapTimeToFrame(newStartTime, projectFps);
-
- // Calculate drop position relative to tracks
- const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
-
- // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
- let dropPosition: "above" | "on" | "below";
- if (mouseY < 20) {
- dropPosition = "above";
- } else if (mouseY > 40) {
- dropPosition = "below";
- } else {
- dropPosition = "on";
- }
-
- try {
- if (hasTimelineElement) {
- // Handle timeline element movement
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (!timelineElementData) return;
-
- const {
- elementId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineElementData);
-
- // Find the element being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: TimelineElementType) => c.id === elementId
- );
-
- if (!movingElement) {
- toast.error("Element not found");
- return;
- }
-
- // Check for overlaps with existing elements (excluding the moving element itself)
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
-
- // Adjust position based on where user clicked on the element
- const adjustedStartTime = newStartTime - clickOffsetTime;
- const snappedStartTime = getDropSnappedTime(
- adjustedStartTime,
- movingElementDuration,
- elementId
- );
- const finalStartTime = Math.max(0, snappedStartTime);
- const movingElementEnd = finalStartTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- // Skip the element being moved if it's on the same track
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- finalStartTime < existingEnd && movingElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move element here - it would overlap with existing elements"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- let targetTrackId = track.id;
- let targetTrack = track;
-
- // Handle position-aware track creation for text
- if (track.type !== "text" || dropPosition !== "on") {
- // Text tracks should go above the main track
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is not text type
- // Insert above main track if main track exists, otherwise at top
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- } else {
- insertIndex = 0; // Top of timeline
- }
- }
-
- targetTrackId = insertTrackAt("text", insertIndex);
- // Get the updated tracks array after creating the new track
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = 5; // Default text duration
- const textSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = textSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- textSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- ...DEFAULT_TEXT_ELEMENT,
- name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
- content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
- startTime: textSnappedTime,
- });
- } else {
- // Handle media items
- const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- let targetTrackId = track.id;
-
- // Check if track type is compatible
- const isVideoOrImage =
- dragData.type === "video" || dragData.type === "image";
- const isAudio = dragData.type === "audio";
- const isCompatible = isVideoOrImage
- ? canElementGoOnTrack("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", track.type)
- : false;
-
- let targetTrack = tracks.find((t) => t.id === targetTrackId);
-
- // Handle position-aware track creation for media elements
- if (!isCompatible || dropPosition !== "on") {
- if (isVideoOrImage) {
- // For video/image, check if we need a main track or additional media track
- const mainTrack = getMainTrack(tracks);
-
- if (!mainTrack) {
- // No main track exists, create it
- targetTrackId = addTrack("media");
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- } else if (
- mainTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- // Main track exists and is empty, use it
- targetTrackId = mainTrack.id;
- targetTrack = mainTrack;
- } else {
- // Create new media track
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
- }
-
- targetTrackId = insertTrackAt("audio", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- }
-
- if (!targetTrack) return;
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = mediaItem.duration || 5;
- const mediaSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = mediaSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- mediaSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: mediaSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- } else if (hasFiles) {
- // External file drops
- const { activeProject } = useProjectStore.getState();
- const { addMediaFile } = useMediaStore.getState();
- const { addElementToTrack } = useTimelineStore.getState();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- // Process and add files to new timeline tracks at playhead position
- processMediaFiles(e.dataTransfer.files)
- .then(async (processedItems) => {
- for (const processedItem of processedItems) {
- await addMediaFile(activeProject.id, processedItem);
- const currentMediaFiles = mediaFiles;
- const addedItem = currentMediaFiles.find(
- (item) =>
- item.name === processedItem.name &&
- item.url === processedItem.url
- );
-
- if (addedItem) {
- const trackType: TrackType =
- addedItem.type === "audio" ? "audio" : "media";
- const targetTrackId = insertTrackAt(trackType, 0);
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- })
- .catch((error) => {
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- });
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+}
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-elements-container min-w-full"
- >
- {track.elements.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
- </div>
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ shouldIgnoreClick,
+}: TimelineTrackContentProps) {
+ const editor = useEditor();
+ const { isElementSelected, clearElementSelection } = useElementSelection();
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitSelected } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const duration = editor.timeline.getTotalDuration();
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- splitSelected(splitTime, track.id, element.id);
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
- const handleElementDuplicate = () => {
- const { addElementToTrack } = useTimelineStore.getState();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
+ const hasSelectedElements = track.elements.some((element) =>
+ isElementSelected({ trackId: track.id, elementId: element.id }),
+ );
- const handleElementDelete = () => {
- const { deleteSelected } = useTimelineStore.getState();
- deleteSelected(track.id, element.id);
- };
+ return (
+ <button
+ className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
+ onClick={() => {
+ if (shouldIgnoreClick?.()) return;
+ clearElementSelection();
+ }}
+ onMouseDown={(event) => {
+ onTrackMouseDown?.(event);
+ }}
+ type="button"
+ >
+ <div className="relative h-full min-w-full">
+ {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" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ />
+ );
+ })
+ )}
+ </div>
+ </button>
+ );
}