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.tsx535
-rw-r--r--apps/web/src/components/editor/timeline/snap-indicator.tsx52
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx554
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx92
-rw-r--r--apps/web/src/components/editor/timeline/timeline-ruler.tsx108
-rw-r--r--apps/web/src/components/editor/timeline/timeline-tick.tsx39
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx354
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx117
11 files changed, 0 insertions, 2166 deletions
diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx
deleted file mode 100644
index bbc5cb83..00000000
--- a/apps/web/src/components/editor/timeline/audio-waveform.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-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
deleted file mode 100644
index cfcb1e23..00000000
--- a/apps/web/src/components/editor/timeline/bookmarks.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-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
deleted file mode 100644
index f31c6e28..00000000
--- a/apps/web/src/components/editor/timeline/drag-line.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 2ff17345..00000000
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ /dev/null
@@ -1,535 +0,0 @@
-"use client";
-
-import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- Delete02Icon,
- TaskAdd02Icon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "../../ui/context-menu";
-import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import { useState, useRef, useCallback } from "react";
-import { TimelineTrackContent } from "./timeline-track";
-import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "../selection-box";
-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 {
- TIMELINE_CONSTANTS,
- TRACK_ICONS,
-} from "@/constants/timeline-constants";
-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() {
- 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 });
-
- // 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);
-
- // state
- const [isResizing, setIsResizing] = useState(false);
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null,
- );
-
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
- const handleResizeStateChange = useCallback(
- ({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
- setIsResizing(nextIsResizing);
- if (!nextIsResizing) {
- setCurrentSnapPoint(null);
- }
- },
- [],
- );
-
- const timelineDuration = timeline.getTotalDuration() || 0;
- const minZoomLevel = getTimelineZoomMin({
- duration: timelineDuration,
- containerWidth: tracksContainerRef.current?.clientWidth,
- });
-
- const savedViewState = editor.project.getTimelineViewState();
-
- const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
- useTimelineZoom({
- containerRef: timelineRef,
- minZoom: minZoomLevel,
- initialZoom: savedViewState?.zoomLevel,
- initialScrollLeft: savedViewState?.scrollLeft,
- initialPlayheadTime: savedViewState?.playheadTime,
- tracksScrollRef,
- rulerScrollRef: tracksScrollRef,
- });
-
- const {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- } = useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- headerRef: timelineHeaderRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
- useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef: tracksScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
- containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
- zoomLevel,
- });
-
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- shouldIgnoreClick,
- } = useSelectionBox({
- containerRef: tracksContainerRef,
- onSelectionComplete: (elements) => {
- setElementSelection({ elements });
- },
- tracksScrollRef,
- zoomLevel,
- });
-
- 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,
- );
-
- const showSnapIndicator =
- snappingEnabled &&
- currentSnapPoint !== null &&
- (dragState.isDragging || isResizing);
-
- const {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
- } = useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef: tracksScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration: timeline.getTotalDuration(),
- isSelecting,
- clearSelectedElements: clearElementSelection,
- seek,
- });
-
- useScrollSync({
- tracksScrollRef,
- trackLabelsScrollRef,
- });
-
- const timelineHeaderHeight =
- timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
-
- 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)}
- />
-
- <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>
-
- <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}
- tracksScrollRef={tracksScrollRef}
- 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={(event) => {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackClick={handleTracksClick}
- 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_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
deleted file mode 100644
index 914edccb..00000000
--- a/apps/web/src/components/editor/timeline/snap-indicator.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"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 | null>;
- trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-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-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
deleted file mode 100644
index fd292ad8..00000000
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ /dev/null
@@ -1,554 +0,0 @@
-"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 {
- getTrackClasses,
- getTrackHeight,
- canElementHaveAudio,
- canElementBeHidden,
- hasMediaId,
-} from "@/lib/timeline";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../../ui/context-menu";
-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";
-
-function getDisplayShortcut(action: TAction) {
- const { defaultShortcuts } = getActionDefinition(action);
- if (!defaultShortcuts?.length) {
- return "";
- }
-
- return uppercase({
- string: defaultShortcuts[0].replace("+", " "),
- });
-}
-
-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;
-}
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onSnapPointChange,
- onResizeStateChange,
- onElementMouseDown,
- onElementClick,
- dragState,
-}: TimelineElementProps) {
- const editor = useEditor();
- const { selectedElements } = useElementSelection();
- const { requestRevealMedia } = useAssetsPanelStore();
-
- const mediaAssets = editor.media.getAssets();
- let mediaAsset: MediaAsset | null = null;
-
- if (hasMediaId(element)) {
- mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- }
-
- const hasAudio = mediaSupportsAudio({ media: mediaAsset });
-
- const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
- useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onSnapPointChange,
- onResizeStateChange,
- });
-
- const isCurrentElementSelected = selectedElements.some(
- (selected) =>
- selected.elementId === element.id && selected.trackId === track.id,
- );
-
- 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 handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
- event.stopPropagation();
- if (hasMediaId(element)) {
- requestRevealMedia(element.mediaId);
- }
- };
-
- const isMuted = canElementHaveAudio(element) && element.muted === true;
-
- 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>
- );
-}
-
-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>
-
- {(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>
-
- {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>
- );
-}
-
-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>
- );
- }
-
- 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 (element.type === "audio") {
- const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
-
- const audioUrl =
- element.sourceType === "library"
- ? element.sourceUrl
- : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
-
- 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>
- );
- }
-
- return (
- <span className="text-foreground/80 truncate text-xs">
- {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>
- );
- }
-
- 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;
-
- 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 (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
- );
-}
-
-function CopyMenuItem() {
- return (
- <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
- Copy
- </ActionMenuItem>
- );
-}
-
-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} />
- );
- };
-
- return (
- <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
- {isMuted ? "Unmute" : "Mute"}
- </ActionMenuItem>
- );
-}
-
-function VisibilityMenuItem({
- element,
- isMultipleSelected,
- isCurrentElementSelected,
-}: {
- element: TimelineElementType;
- isMultipleSelected: boolean;
- isCurrentElementSelected: boolean;
-}) {
- const isHidden = canElementBeHidden(element) && element.hidden;
-
- const getIcon = () => {
- if (isMultipleSelected && isCurrentElementSelected) {
- return <HugeiconsIcon icon={ViewOffSlashIcon} />;
- }
- return isHidden ? (
- <HugeiconsIcon icon={ViewIcon} />
- ) : (
- <HugeiconsIcon icon={ViewOffSlashIcon} />
- );
- };
-
- return (
- <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
- {isHidden ? "Show" : "Hide"}
- </ActionMenuItem>
- );
-}
-
-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>
- );
-}
-
-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-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
deleted file mode 100644
index 8af35dc9..00000000
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
-import { useEditor } from "@/hooks/use-editor";
-
-interface TimelinePlayheadProps {
- zoomLevel: number;
- rulerRef: React.RefObject<HTMLDivElement | null>;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- playheadRef?: React.RefObject<HTMLDivElement | null>;
- isSnappingToPlayhead?: boolean;
-}
-
-export function TimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
-}: TimelinePlayheadProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
-
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const timelineContainerHeight =
- tracksScrollRef.current?.clientHeight ??
- timelineRef.current?.clientHeight ??
- 400;
- const totalHeight = Math.max(0, timelineContainerHeight - 4);
-
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const leftPosition = timelinePosition;
-
- const handlePlayheadKeyDown = (
- event: React.KeyboardEvent<HTMLDivElement>,
- ) => {
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
-
- 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),
- );
-
- editor.playback.seek({ time: nextTime });
- };
-
- 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" />
-
- <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>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
deleted file mode 100644
index dfa0534b..00000000
--- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import type { JSX } from "react";
-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 { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
-import { TimelineTick } from "./timeline-tick";
-
-interface TimelineRulerProps {
- zoomLevel: number;
- dynamicTimelineWidth: number;
- rulerRef: React.Ref<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLElement | null>;
- 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,
- tracksScrollRef,
- 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 { scrollLeft, viewportWidth } = useScrollPosition({
- scrollRef: tracksScrollRef,
- });
-
- const bufferPx = 200;
- const visibleStartTime = Math.max(
- 0,
- (scrollLeft - bufferPx) / pixelsPerSecond,
- );
- const visibleEndTime =
- (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
-
- const startTickIndex = Math.max(
- 0,
- Math.floor(visibleStartTime / tickIntervalSeconds),
- );
- const endTickIndex = Math.min(
- tickCount - 1,
- Math.ceil(visibleEndTime / tickIntervalSeconds),
- );
-
- const timelineTicks: Array<JSX.Element> = [];
- for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; 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
deleted file mode 100644
index 9402d25e..00000000
--- a/apps/web/src/components/editor/timeline/timeline-tick.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"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
deleted file mode 100644
index 6ac6fba5..00000000
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip";
-import { Button } from "@/components/ui/button";
-import { SkipBack, SplitSquareHorizontal } from "lucide-react";
-import {
- SplitButton,
- SplitButtonLeft,
- SplitButtonRight,
- SplitButtonSeparator,
-} from "@/components/ui/split-button";
-import { Slider } from "@/components/ui/slider";
-import { formatTimeCode } from "@/lib/time";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
-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,
- minZoom,
- setZoomLevel,
-}: {
- zoomLevel: number;
- minZoom: number;
- setZoomLevel: ({ zoom }: { zoom: number }) => void;
-}) {
- const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
- const newZoomLevel =
- direction === "in"
- ? Math.min(
- TIMELINE_CONSTANTS.ZOOM_MAX,
- zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
- )
- : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
- 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 />
-
- <div className="bg-border mx-1 h-6 w-px" />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- tooltip="Split element"
- onClick={({ event }) => handleAction({ action: "split", event })}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={AlignLeftIcon} />}
- tooltip="Split left"
- onClick={({ event }) => handleAction({ action: "split-left", event })}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={AlignRightIcon} />}
- tooltip="Split right"
- onClick={({ event }) =>
- handleAction({ action: "split-right", event })
- }
- />
-
- <ToolbarButton
- icon={<SplitSquareHorizontal />}
- tooltip="Coming soon" /* separate audio */
- disabled={true}
- onClick={({ event: _event }) => {}}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- tooltip="Duplicate element"
- onClick={({ event }) =>
- handleAction({ action: "duplicate-selected", event })
- }
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Coming soon" /* freeze frame */
- disabled={true}
- onClick={({ event: _event }) => {}}
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- tooltip="Delete element"
- onClick={({ event }) =>
- handleAction({ action: "delete-selected", event })
- }
- />
-
- <div className="bg-border mx-1 h-6 w-px" />
-
- <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>
- );
-}
-
-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();
-
- 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()}
- />
-
- <ToolbarButton
- icon={
- <HugeiconsIcon
- icon={Link04Icon}
- className={cn(
- rippleEditingEnabled ? "text-primary" : "",
- "scale-110",
- )}
- />
- }
- tooltip="Ripple editing"
- onClick={() => toggleRippleEditing()}
- />
- </TooltipProvider>
-
- <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={SearchMinusIcon} />
- </Button>
- <Slider
- className="w-28"
- value={[zoomToSlider({ zoomLevel, minZoom })]}
- onValueChange={(values) =>
- onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
- }
- min={0}
- max={1}
- step={0.005}
- />
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={() => onZoom({ direction: "in" })}
- >
- <HugeiconsIcon icon={SearchAddIcon} />
- </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
deleted file mode 100644
index 47a9f282..00000000
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client";
-
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/types/timeline";
-import type { TimelineElement as TimelineElementType } from "@/types/timeline";
-import type { 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";
-
-interface TimelineTrackContentProps {
- track: TimelineTrack;
- zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- 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;
- onTrackClick?: (event: React.MouseEvent) => void;
- shouldIgnoreClick?: () => boolean;
-}
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
- onSnapPointChange,
- onResizeStateChange,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackClick,
- shouldIgnoreClick,
-}: TimelineTrackContentProps) {
- const editor = useEditor();
- const { isElementSelected, clearElementSelection } = useElementSelection();
-
- const duration = editor.timeline.getTotalDuration();
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
- });
-
- const hasSelectedElements = track.elements.some((element) =>
- isElementSelected({ trackId: track.id, elementId: element.id }),
- );
-
- return (
- <button
- className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
- onClick={(event) => {
- if (shouldIgnoreClick?.()) return;
- clearElementSelection();
- onTrackClick?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- 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}
- onSnapPointChange={onSnapPointChange}
- onResizeStateChange={onResizeStateChange}
- onElementMouseDown={(event, element) =>
- onElementMouseDown({ event, element, track })
- }
- onElementClick={(event, element) =>
- onElementClick({ event, element, track })
- }
- dragState={dragState}
- />
- );
- })
- )}
- </div>
- </button>
- );
-}