summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/components/editor/panels/timeline
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline')
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx275
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx345
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx427
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx29
-rw-r--r--apps/web/src/components/editor/panels/timeline/drop-target.ts262
-rw-r--r--apps/web/src/components/editor/panels/timeline/expanded-layout.ts117
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx235
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx334
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts704
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts178
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx944
-rw-r--r--apps/web/src/components/editor/panels/timeline/interaction.ts4
-rw-r--r--apps/web/src/components/editor/panels/timeline/layers.ts6
-rw-r--r--apps/web/src/components/editor/panels/timeline/layout.ts20
-rw-r--r--apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts135
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx50
-rw-r--r--apps/web/src/components/editor/panels/timeline/theme.ts31
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx1305
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx114
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx141
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx42
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx376
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx132
-rw-r--r--apps/web/src/components/editor/panels/timeline/track-layout.ts60
26 files changed, 0 insertions, 6386 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx b/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
deleted file mode 100644
index 208f3beb..00000000
--- a/apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-"use client";
-
-import { useCallback, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getDbFromLinePos,
- getLinePosFromDb,
-} from "@/lib/timeline/audio-display";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { hasAnimatedVolume } from "@/lib/timeline/audio-state";
-import type { AudioElement } from "@/lib/timeline/types";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import { cn } from "@/utils/ui";
-
-const HIT_AREA_HEIGHT_PX = 14;
-const TOOLTIP_OFFSET_PX = 10;
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-function clampVolume({ value }: { value: number }): number {
- return clamp({
- value: snapToStep({ value, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
-}
-
-function getVolumeFromPointer({
- clientY,
- rect,
-}: {
- clientY: number;
- rect: DOMRect;
-}): number {
- const clampedOffset = clamp({
- value: clientY - rect.top,
- min: 0,
- max: rect.height,
- });
- const progressPercent =
- rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
- return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
-}
-
-export function AudioVolumeLine({
- element,
- trackId,
-}: {
- element: AudioElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const surfaceRef = useRef<HTMLDivElement>(null);
- const activePointerIdRef = useRef<number | null>(null);
- const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
- const lastPreviewVolumeRef = useRef(
- element.volume ?? DEFAULTS.element.volume,
- );
- const hasChangedRef = useRef(false);
- const [isDragging, setIsDragging] = useState(false);
- const [tooltipClientPos, setTooltipClientPos] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- const hasAnimatedEnvelope = hasAnimatedVolume({ element });
- const currentVolume = element.volume ?? DEFAULTS.element.volume;
- const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
-
- const volumeLabel = `${formatNumberForDisplay({
- value: currentVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- })} dB`;
-
- const previewVolume = useCallback(
- (nextVolume: number) => {
- if (
- isNearlyEqual({
- leftValue: nextVolume,
- rightValue: lastPreviewVolumeRef.current,
- })
- ) {
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { volume: nextVolume },
- },
- ],
- });
- lastPreviewVolumeRef.current = nextVolume;
- hasChangedRef.current = !isNearlyEqual({
- leftValue: startVolumeRef.current,
- rightValue: nextVolume,
- });
- },
- [editor, element.id, trackId],
- );
-
- const finishDrag = useCallback(
- ({ shouldCommit }: { shouldCommit: boolean }) => {
- activePointerIdRef.current = null;
- setIsDragging(false);
-
- if (shouldCommit && hasChangedRef.current) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.discardPreview();
- }
-
- hasChangedRef.current = false;
- lastPreviewVolumeRef.current = startVolumeRef.current;
- setTooltipClientPos(null);
- },
- [editor],
- );
-
- const updateFromPointer = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- const rect = surfaceRef.current?.getBoundingClientRect();
- if (!rect) {
- return;
- }
-
- setTooltipClientPos({
- x: clientX + TOOLTIP_OFFSET_PX,
- y: clientY - TOOLTIP_OFFSET_PX,
- });
- previewVolume(getVolumeFromPointer({ clientY, rect }));
- },
- [previewVolume],
- );
-
- const handleClick = useCallback((event: React.MouseEvent) => {
- event.preventDefault();
- event.stopPropagation();
- }, []);
-
- const handleMouseDown = useCallback((event: React.MouseEvent) => {
- event.stopPropagation();
- }, []);
-
- const handlePointerDown = useCallback(
- (event: React.PointerEvent<HTMLDivElement>) => {
- if (event.button !== 0) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- editor.selection.setSelectedElements({
- elements: [{ trackId, elementId: element.id }],
- });
- activePointerIdRef.current = event.pointerId;
- startVolumeRef.current = currentVolume;
- lastPreviewVolumeRef.current = currentVolume;
- hasChangedRef.current = false;
- setIsDragging(true);
- event.currentTarget.setPointerCapture(event.pointerId);
- updateFromPointer({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- },
- [currentVolume, editor.selection, element.id, trackId, updateFromPointer],
- );
-
- const handlePointerMove = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- updateFromPointer({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- },
- [updateFromPointer],
- );
-
- const handlePointerUp = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- finishDrag({ shouldCommit: true });
- },
- [finishDrag],
- );
-
- const handlePointerCancel = useCallback(
- (event: React.PointerEvent) => {
- if (activePointerIdRef.current !== event.pointerId) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- finishDrag({ shouldCommit: false });
- },
- [finishDrag],
- );
-
- const handleLostPointerCapture = useCallback(() => {
- if (activePointerIdRef.current === null) {
- return;
- }
-
- finishDrag({ shouldCommit: hasChangedRef.current });
- }, [finishDrag]);
-
- if (hasAnimatedEnvelope) {
- return null;
- }
-
- return (
- <div className="pointer-events-none absolute inset-0">
- <div ref={surfaceRef} className="absolute inset-0">
- <div
- className={cn(
- "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
- isDragging
- ? "border-white"
- : "border-white/50 group-hover/audio:border-white/80",
- )}
- style={{ top: lineTop }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
- <div
- className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
- style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
- onClick={handleClick}
- onMouseDown={handleMouseDown}
- onPointerDown={handlePointerDown}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerCancel}
- onLostPointerCapture={handleLostPointerCapture}
- title="Drag to adjust clip volume"
- />
- {isDragging &&
- tooltipClientPos &&
- createPortal(
- <div
- className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
- style={{
- transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
- }}
- >
- {volumeLabel}
- </div>,
- document.body,
- )}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
deleted file mode 100644
index a94267dd..00000000
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
-import { useResizeObserver } from "@/hooks/use-resize-observer";
-import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
-import {
- buildWaveformSampleBuckets,
- sampleSourceWaveformSummary,
- type SourceWaveformSummary,
-} from "@/lib/media/waveform-summary";
-import type { RetimeConfig } from "@/lib/timeline";
-import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display";
-import { waveformCache } from "@/services/waveform-cache/service";
-import { findScrollParent } from "@/utils/browser";
-import { cn } from "@/utils/ui";
-
-const BAR_WIDTH = 1;
-const BAR_GAP = 1;
-const BAR_STEP = BAR_WIDTH + BAR_GAP;
-const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
-export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
-
-function sampleGainAtClipTime({
- samples,
- clipTimeSec,
- clipDurationSec,
-}: {
- samples: number[];
- clipTimeSec: number;
- clipDurationSec: number;
-}): number {
- if (samples.length === 0 || clipDurationSec <= 0) {
- return 1;
- }
-
- const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
- const rawIndex = progress * (samples.length - 1);
- const lo = Math.floor(rawIndex);
- const hi = Math.min(samples.length - 1, lo + 1);
- return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
-}
-
-interface AudioWaveformProps {
- sourceKey: string;
- sourceFile?: File;
- audioUrl?: string;
- audioBuffer?: AudioBuffer;
- gainSamples?: number[];
- pixelsPerSecond: number;
- clipDurationSec: number;
- retime?: RetimeConfig;
- sourceStartSec: number;
- color?: string;
- burnColor?: string;
- className?: string;
-}
-
-export function AudioWaveform({
- sourceKey,
- sourceFile,
- audioUrl,
- audioBuffer,
- gainSamples,
- pixelsPerSecond,
- clipDurationSec,
- retime,
- sourceStartSec,
- color = TIMELINE_AUDIO_WAVEFORM_COLOR,
- burnColor = WAVEFORM_BURN_COLOR,
- className = "",
-}: AudioWaveformProps) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const summaryRef = useRef<SourceWaveformSummary | null>(null);
- const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
- const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
- const clipDurationSecRef = useRef<number>(clipDurationSec);
- const retimeRef = useRef<RetimeConfig | undefined>(retime);
- const sourceStartSecRef = useRef<number>(sourceStartSec);
- const scrollParentRef = useRef<HTMLElement | null>(null);
- const heightRef = useRef<number>(0);
- const lastRenderSignatureRef = useRef<string | null>(null);
-
- gainSamplesRef.current = gainSamples;
- pixelsPerSecondRef.current = pixelsPerSecond;
- clipDurationSecRef.current = clipDurationSec;
- retimeRef.current = retime;
- sourceStartSecRef.current = sourceStartSec;
-
- const clearCanvas = useCallback(() => {
- const canvas = canvasRef.current;
- if (!canvas) {
- return;
- }
-
- const ctx = canvas.getContext("2d");
- if (ctx) {
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- }
- lastRenderSignatureRef.current = null;
- }, []);
-
- const drawVisible = useCallback(() => {
- const container = containerRef.current;
- const canvas = canvasRef.current;
- const summary = summaryRef.current;
- const height = heightRef.current;
-
- if (!container || !canvas || !summary || height <= 0) {
- clearCanvas();
- return;
- }
-
- const containerRect = container.getBoundingClientRect();
- const elementWidth = containerRect.width;
- if (elementWidth <= 0) {
- clearCanvas();
- return;
- }
-
- const scrollParent = scrollParentRef.current;
-
- let clipLeft: number;
- let clipRight: number;
-
- if (scrollParent) {
- const parentRect = scrollParent.getBoundingClientRect();
- clipLeft = Math.max(0, parentRect.left - containerRect.left);
- clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
- } else {
- clipLeft = Math.max(0, -containerRect.left);
- clipRight = Math.min(
- elementWidth,
- window.innerWidth - containerRect.left,
- );
- }
-
- const visibleWidth = clipRight - clipLeft;
- if (visibleWidth <= 0) {
- clearCanvas();
- return;
- }
-
- const dpr = window.devicePixelRatio || 1;
- const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
- const canvasH = Math.max(1, Math.round(height * dpr));
- const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
- const pixelsPerSecondValue = pixelsPerSecondRef.current;
- const clipDurationSecValue = clipDurationSecRef.current;
- const samples = gainSamplesRef.current;
- const renderSignature = JSON.stringify({
- elementWidth,
- clipLeft,
- clipRight,
- visibleWidth,
- canvasW,
- canvasH,
- barCount,
- dpr,
- clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
- pixelsPerSecond: pixelsPerSecondValue,
- retime: retimeRef.current ?? null,
- summarySourceKey: summary.sourceKey,
- summarySampleRate: summary.sampleRate,
- summaryTotalSamples: summary.totalSamples,
- summaryBucketSize: summary.bucketSize,
- gainSamples: samples ?? null,
- color,
- burnColor,
- });
- if (lastRenderSignatureRef.current === renderSignature) {
- return;
- }
- lastRenderSignatureRef.current = renderSignature;
-
- canvas.width = canvasW;
- canvas.height = canvasH;
- canvas.style.width = `${visibleWidth}px`;
- canvas.style.height = `${height}px`;
- canvas.style.left = `${clipLeft}px`;
-
- const backingScaleX = dpr;
- const backingScaleY = canvasH / height;
-
- const sampleBuckets = buildWaveformSampleBuckets({
- clipLeftPx: clipLeft,
- clipRightPx: clipRight,
- barCount,
- pixelsPerSecond: pixelsPerSecondValue,
- clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
- retime: retimeRef.current,
- sampleRate: summary.sampleRate,
- maxSampleExclusive: summary.totalSamples,
- barStepPx: BAR_STEP,
- });
- const amplitudes = sampleSourceWaveformSummary({
- summary,
- buckets: sampleBuckets,
- });
-
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return;
- }
-
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.clearRect(0, 0, canvasW, canvasH);
-
- const clipBottom = canvasH;
-
- for (let i = 0; i < barCount; i++) {
- const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
- const clipCenterSec = Math.max(
- 0,
- Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
- );
- const gain =
- samples != null
- ? sampleGainAtClipTime({
- samples,
- clipTimeSec: clipCenterSec,
- clipDurationSec: clipDurationSecValue,
- })
- : 1;
- const amplitude = Math.max(0, amplitudes[i] ?? 0);
- const outputAmplitude = amplitude * Math.max(0, gain);
- const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
- const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
- if (barH <= 0) {
- continue;
- }
-
- const barLeft = i * BAR_STEP;
- const barRight = barLeft + BAR_WIDTH;
- const deviceLeft = Math.round(barLeft * backingScaleX);
- const deviceRight = Math.max(
- deviceLeft + 1,
- Math.round(barRight * backingScaleX),
- );
- const deviceTop = Math.round((height - barH) * backingScaleY);
- const deviceHeight = Math.max(1, clipBottom - deviceTop);
-
- ctx.fillStyle = color;
- ctx.fillRect(
- deviceLeft,
- deviceTop,
- deviceRight - deviceLeft,
- deviceHeight,
- );
-
- if (outputAmplitude > 1) {
- const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
- ctx.fillStyle = burnColor;
- ctx.fillRect(
- deviceLeft,
- deviceTop,
- deviceRight - deviceLeft,
- burnHeight,
- );
- }
- }
- }, [burnColor, clearCanvas, color]);
-
- useEffect(() => {
- let isCancelled = false;
- summaryRef.current = null;
- clearCanvas();
-
- void waveformCache
- .getSourceSummary({
- sourceKey,
- audioBuffer,
- sourceFile,
- audioUrl,
- })
- .then((summary) => {
- if (isCancelled) {
- return;
- }
- summaryRef.current = summary;
- drawVisible();
- })
- .catch(() => {
- // Waveform loading failed (e.g. corrupt file, unsupported format).
- // Fail silently — a missing waveform is preferable to an error state.
- if (!isCancelled) {
- clearCanvas();
- }
- });
-
- return () => {
- isCancelled = true;
- };
- }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
- useLayoutEffect(() => {
- drawVisible();
- }, [
- drawVisible,
- gainSamples,
- pixelsPerSecond,
- clipDurationSec,
- retime,
- sourceStartSec,
- ]);
-
- useEffect(() => {
- const container = containerRef.current;
- if (!container) {
- return;
- }
-
- scrollParentRef.current = findScrollParent({ element: container });
- const scrollParent = scrollParentRef.current;
- if (!scrollParent) {
- return;
- }
-
- const handleScroll = () => {
- drawVisible();
- };
- scrollParent.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollParent.removeEventListener("scroll", handleScroll);
- }, [drawVisible]);
-
- const onResize = useCallback(
- (entry: ResizeObserverEntry) => {
- heightRef.current = entry.contentRect.height;
- drawVisible();
- },
- [drawVisible],
- );
-
- useResizeObserver({ ref: containerRef, onResize });
-
- return (
- <div ref={containerRef} className={cn("relative size-full", className)}>
- <canvas ref={canvasRef} className="absolute bottom-0" />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
deleted file mode 100644
index c432b7d4..00000000
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ /dev/null
@@ -1,427 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { EditorCore } from "@/core";
-import { useEditor } from "@/hooks/use-editor";
-import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
-import {
- DEFAULT_TIMELINE_BOOKMARK_COLOR,
-} from "./theme";
-import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
-import { DEFAULT_FPS } from "@/lib/fps/defaults";
-import { snappedSeekTime } from "opencut-wasm";
-import {
- ArrowTurnBackwardIcon,
- Delete02Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/lib/timeline";
-import {
- Popover,
- PopoverAnchor,
- PopoverContent,
-} from "@/components/ui/popover";
-import { Input } from "@/components/ui/input";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import {
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-
-const MIN_BOOKMARK_WIDTH_PX = 2;
-const BOOKMARK_MARKER_WIDTH_PX = 12;
-const BOOKMARK_MARKER_HEIGHT_PX = 15;
-const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
-const BOOKMARK_MARKER_CLIP_PATH =
- "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
-
-function seekToBookmarkTime({
- editor,
- time,
-}: {
- editor: EditorCore;
- time: number;
-}) {
- const activeProject = editor.project.getActive();
- const duration = editor.timeline.getTotalDuration();
- const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
- const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
- editor.playback.seek({ time: snappedTime });
-}
-
-interface TimelineBookmarksRowProps {
- zoomLevel: number;
- dynamicTimelineWidth: number;
- dragState: BookmarkDragState;
- onBookmarkMouseDown: (params: {
- event: React.MouseEvent;
- bookmark: Bookmark;
- }) => void;
- handleWheel: (event: React.WheelEvent) => void;
- handleTimelineContentClick: (event: React.MouseEvent) => void;
- handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
- handleRulerMouseDown: (event: React.MouseEvent) => void;
-}
-
-export function TimelineBookmarksRow({
- zoomLevel,
- dynamicTimelineWidth,
- dragState,
- onBookmarkMouseDown,
- handleWheel,
- handleTimelineContentClick,
- handleRulerTrackingMouseDown,
- handleRulerMouseDown,
-}: TimelineBookmarksRowProps) {
- const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
-
- return (
- <div
- className="relative flex-1 overflow-hidden"
- style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
- >
- <button
- className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
- style={{
- height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
- width: `${dynamicTimelineWidth}px`,
- }}
- aria-label="Timeline ruler"
- type="button"
- onWheel={handleWheel}
- onClick={(event) => {
- if (!event.currentTarget.contains(event.target as Node)) return;
- handleTimelineContentClick(event);
- }}
- onMouseDown={(event) => {
- if (!event.currentTarget.contains(event.target as Node)) return;
- handleRulerMouseDown(event);
- handleRulerTrackingMouseDown(event);
- }}
- >
- {bookmarks.map((bookmark) => (
- <TimelineBookmark
- key={`bookmark-${bookmark.time}`}
- bookmark={bookmark}
- zoomLevel={zoomLevel}
- dragState={dragState}
- onBookmarkMouseDown={onBookmarkMouseDown}
- />
- ))}
- </button>
- </div>
- );
-}
-
-function TimelineBookmark({
- bookmark,
- zoomLevel,
- dragState,
- onBookmarkMouseDown,
-}: {
- bookmark: Bookmark;
- zoomLevel: number;
- dragState: BookmarkDragState;
- onBookmarkMouseDown: (params: {
- event: React.MouseEvent;
- bookmark: Bookmark;
- }) => void;
-}) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
-
- const isDragging =
- dragState.isDragging &&
- dragState.bookmarkTime !== null &&
- dragState.bookmarkTime === bookmark.time;
-
- const displayTime = isDragging ? dragState.currentTime : bookmark.time;
- const time = bookmark.time;
- const bookmarkDuration = bookmark.duration ?? 0;
- const durationWidth =
- bookmarkDuration > 0
- ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
- : 0;
- const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
- const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
- const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
- const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
- const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
- const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
-
- const handleSeek = () => seekToBookmarkTime({ editor, time });
-
- const handleClick = (event: React.MouseEvent) => {
- event.preventDefault();
- event.stopPropagation();
- if (event.detail === 2) {
- setIsPopoverOpen(true);
- } else {
- handleSeek();
- }
- };
-
- const handleKeyDown = (event: React.KeyboardEvent) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- handleSeek();
- };
-
- const handleMouseDown = (event: React.MouseEvent) => {
- onBookmarkMouseDown({ event, bookmark });
- event.preventDefault();
- event.stopPropagation();
- };
-
- return (
- <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
- <PopoverAnchor asChild>
- <button
- className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
- style={{
- left: `${bookmarkLeft}px`,
- width: `${bookmarkWidth}px`,
- }}
- aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
- type="button"
- onMouseDown={handleMouseDown}
- onClick={handleClick}
- onKeyDown={handleKeyDown}
- >
- {hasDurationRange ? (
- <div
- className="absolute opacity-30"
- style={{
- top: 1.5,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
- left: BOOKMARK_HALF_WIDTH_PX,
- width: durationWidth,
- backgroundColor: iconColor,
- }}
- />
- ) : null}
- <div
- className="absolute top-0 overflow-hidden"
- style={{
- left: 0,
- width: BOOKMARK_HALF_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- }}
- >
- <div
- className="absolute inset-0"
- style={{
- width: BOOKMARK_MARKER_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: "hsl(var(--background))",
- }}
- />
- <div
- className="absolute"
- style={{
- top: 1,
- left: 1,
- width: BOOKMARK_MARKER_WIDTH_PX - 2,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: iconColor,
- }}
- />
- </div>
- <div
- className="absolute top-0 overflow-hidden"
- style={{
- left: rightHalfLeft,
- width: BOOKMARK_HALF_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- }}
- >
- <div
- className="absolute top-0"
- style={{
- left: -BOOKMARK_HALF_WIDTH_PX,
- width: BOOKMARK_MARKER_WIDTH_PX,
- height: BOOKMARK_MARKER_HEIGHT_PX,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: "hsl(var(--background))",
- }}
- />
- <div
- className="absolute"
- style={{
- top: 1,
- left: 1 - BOOKMARK_HALF_WIDTH_PX,
- width: BOOKMARK_MARKER_WIDTH_PX - 2,
- height: BOOKMARK_MARKER_HEIGHT_PX - 2,
- clipPath: BOOKMARK_MARKER_CLIP_PATH,
- backgroundColor: iconColor,
- }}
- />
- </div>
- </button>
- </PopoverAnchor>
- <PopoverContent
- className="w-64 flex flex-col gap-3 p-3"
- align="start"
- side="bottom"
- sideOffset={8}
- onOpenAutoFocus={(event) => event.preventDefault()}
- >
- <BookmarkPopoverContent
- bookmark={bookmark}
- time={time}
- timelineDuration={duration}
- onPopoverClose={() => setIsPopoverOpen(false)}
- />
- </PopoverContent>
- </Popover>
- );
-}
-
-function BookmarkPopoverContent({
- bookmark,
- time,
- timelineDuration,
- onPopoverClose,
-}: {
- bookmark: Bookmark;
- time: number;
- timelineDuration: number;
- onPopoverClose: () => void;
-}) {
- const editor = useEditor();
- const [draftColorHex, setDraftColorHex] = useState(
- (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
- .replace("#", "")
- .toUpperCase(),
- );
-
- useEffect(() => {
- setDraftColorHex(
- (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
- .replace("#", "")
- .toUpperCase(),
- );
- }, [bookmark.color]);
-
- const handleRemove = () => {
- editor.scenes.removeBookmark({ time });
- onPopoverClose();
- };
-
- const handleUpdate = ({
- note,
- color,
- duration,
- }: Partial<{ note: string; color: string; duration: number }>) => {
- const updates: Partial<{ note: string; color: string; duration: number }> =
- {};
- if (note !== undefined && note !== bookmark.note) updates.note = note;
- if (
- color !== undefined &&
- color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
- ) {
- updates.color = color;
- }
- if (duration !== undefined && duration !== bookmark.duration) {
- updates.duration = duration;
- }
- if (Object.keys(updates).length === 0) return;
- editor.scenes.updateBookmark({ time, updates });
- };
-
- return (
- <>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Note</Label>
- <Input
- placeholder="Add a note..."
- value={bookmark.note ?? ""}
- onChange={(event) => handleUpdate({ note: event.target.value })}
- className="h-8 text-sm"
- />
- </div>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Color</Label>
- <div className="relative">
- <ColorPicker
- value={uppercase({ string: draftColorHex })}
- onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
- onChangeEnd={(color) =>
- handleUpdate({ color: `#${uppercase({ string: color })}` })
- }
- className="bg-background border"
- />
- {bookmark.color &&
- bookmark.color.replace(/^#/, "").toUpperCase() !==
- DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
- <Button
- type="button"
- variant="text"
- size="text"
- aria-label="Reset to default color"
- className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
- onClick={() =>
- editor.scenes.updateBookmark({
- time,
- updates: { color: undefined },
- })
- }
- >
- <HugeiconsIcon
- icon={ArrowTurnBackwardIcon}
- className="!size-3.5"
- />
- </Button>
- )}
- </div>
- </div>
- <div className="flex flex-col gap-2">
- <Label className="text-xs">Duration</Label>
- <div className="flex items-center gap-1.5">
- <Input
- type="number"
- min={0}
- step={0.1}
- value={bookmark.duration ?? 0}
- onChange={(event) => {
- const parsed = parseFloat(event.target.value);
- const value = Number.isNaN(parsed)
- ? 0
- : clamp({
- value: parsed,
- min: 0,
- max: Math.max(0, timelineDuration - time),
- });
- handleUpdate({ duration: value });
- }}
- className="h-8 text-sm"
- containerClassName="w-full"
- />
- </div>
- </div>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="text-destructive hover:bg-destructive/10"
- onClick={handleRemove}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- handleRemove();
- }
- }}
- aria-label="delete bookmark"
- >
- <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
- Delete
- </Button>
- </>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
deleted file mode 100644
index 89a4c93f..00000000
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { getDropLineY } from "./drop-target";
-import type { TimelineTrack, DropTarget } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-
-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 h-0.5"
- style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts
deleted file mode 100644
index ef6cbd90..00000000
--- a/apps/web/src/components/editor/panels/timeline/drop-target.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
-import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-import { TIMELINE_TRACK_GAP_PX } from "./layout";
-import { getTrackHeight } from "./track-layout";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-
-function findElementAtPosition({
- mouseX,
- tracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
-}: {
- mouseX: number;
- tracks: TimelineTrack[];
- trackIndex: number;
- targetElementTypes: string[];
- pixelsPerSecond: number;
- zoomLevel: number;
-}): { elementId: string; trackId: string } | null {
- const time = Math.round(
- (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
- const track = tracks[trackIndex];
- if (!track || !("elements" in track)) return null;
-
- const hit = track.elements.find(
- (element: TimelineElement) =>
- targetElementTypes.includes(element.type) &&
- element.startTime <= time &&
- time < element.startTime + element.duration,
- );
- if (!hit) return null;
- return { elementId: hit.id, trackId: track.id };
-}
-
-function getTrackAtY({
- mouseY,
- tracks,
- verticalDragDirection,
-}: {
- mouseY: number;
- tracks: TimelineTrack[];
- verticalDragDirection?: "up" | "down" | null;
-}): { trackIndex: number; relativeY: number } | null {
- let cumulativeHeight = 0;
-
- for (let i = 0; i < tracks.length; i++) {
- const trackHeight = getTrackHeight({ type: tracks[i].type });
- const trackTop = cumulativeHeight;
- const trackBottom = trackTop + trackHeight;
-
- if (mouseY >= trackTop && mouseY < trackBottom) {
- return {
- trackIndex: i,
- relativeY: mouseY - trackTop,
- };
- }
-
- if (i < tracks.length - 1 && verticalDragDirection) {
- const gapTop = trackBottom;
- const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
- if (mouseY >= gapTop && mouseY < gapBottom) {
- const isDraggingUp = verticalDragDirection === "up";
- return {
- trackIndex: isDraggingUp ? i : i + 1,
- relativeY: isDraggingUp ? trackHeight - 1 : 0,
- };
- }
- }
-
- cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
- }
-
- return null;
-}
-
-const EMPTY_TARGET_ELEMENT = null;
-
-function fallbackNewTrackDropTarget({
- xPosition,
-}: {
- xPosition: number;
-}): DropTarget {
- return {
- trackIndex: 0,
- isNewTrack: true,
- insertPosition: null,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime,
- isExternalDrop,
- elementDuration,
- pixelsPerSecond,
- zoomLevel,
- verticalDragDirection,
- startTimeOverride,
- excludeElementId,
- targetElementTypes,
-}: ComputeDropTargetParams): DropTarget {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const mainTrackIndex = tracks.overlay.length;
- const xPosition =
- typeof startTimeOverride === "number"
- ? startTimeOverride
- : isExternalDrop
- ? playheadTime
- : Math.round(
- Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
-
- if (orderedTracks.length === 0) {
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- createNewTrackOnly: true,
- },
- });
- const emptyTimelineResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!emptyTimelineResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: emptyTimelineResult.insertIndex,
- isNewTrack: true,
- insertPosition: emptyTimelineResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const trackAtMouse = getTrackAtY({
- mouseY,
- tracks: orderedTracks,
- verticalDragDirection,
- });
-
- if (!trackAtMouse) {
- const isAboveAllTracks = mouseY < 0;
-
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
- hoverDirection: isAboveAllTracks ? "above" : "below",
- createNewTrackOnly: true,
- },
- });
- const outOfBoundsResult =
- placementResult?.kind === "newTrack" ? placementResult : null;
- if (!outOfBoundsResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- return {
- trackIndex: outOfBoundsResult.insertIndex,
- isNewTrack: true,
- insertPosition: outOfBoundsResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- const { trackIndex, relativeY } = trackAtMouse;
- const track = orderedTracks[trackIndex];
-
- if (targetElementTypes && targetElementTypes.length > 0) {
- const targetElement = findElementAtPosition({
- mouseX,
- tracks: orderedTracks,
- trackIndex,
- targetElementTypes,
- pixelsPerSecond,
- zoomLevel,
- });
- if (targetElement) {
- return {
- trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition,
- targetElement,
- };
- }
- }
-
- const trackHeight = getTrackHeight({ type: track.type });
- const placementResult = resolveTrackPlacement({
- tracks,
- elementType,
- timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
- strategy: {
- type: "preferIndex",
- trackIndex,
- hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
- verticalDragDirection,
- },
- });
- if (!placementResult) {
- return fallbackNewTrackDropTarget({ xPosition });
- }
-
- if (placementResult.kind === "existingTrack") {
- const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
-
- return {
- trackIndex: placementResult.trackIndex,
- isNewTrack: false,
- insertPosition: null,
- xPosition: adjustedXPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
- }
-
- return {
- trackIndex: placementResult.insertIndex,
- isNewTrack: true,
- insertPosition: placementResult.insertPosition,
- xPosition,
- targetElement: EMPTY_TARGET_ELEMENT,
- };
-}
-
-export function getDropLineY({
- dropTarget,
- tracks,
-}: {
- dropTarget: DropTarget;
- tracks: TimelineTrack[];
-}): number {
- const safeTrackIndex = Math.min(
- Math.max(dropTarget.trackIndex, 0),
- tracks.length,
- );
- let y = 0;
-
- for (let i = 0; i < safeTrackIndex; i++) {
- y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
- }
-
- return y;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts b/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
deleted file mode 100644
index 6b9734a7..00000000
--- a/apps/web/src/components/editor/panels/timeline/expanded-layout.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import type {
- AnimationPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { TimelineTrack } from "@/lib/timeline";
-import { getElementKeyframes } from "@/lib/animation";
-import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
-
-export interface ExpandedRow {
- propertyPath: AnimationPath;
- label: string;
-}
-
-interface PropertyGroupDefinition {
- matchesPath: (path: AnimationPath) => boolean;
-}
-
-const PROPERTY_GROUPS: PropertyGroupDefinition[] = [
- { matchesPath: (path) => path.startsWith("transform.") || path === "opacity" },
- { matchesPath: (path) => path === "volume" || path === "color" },
- { matchesPath: (path) => path.startsWith("background.") },
- { matchesPath: (path) => path.startsWith("params.") },
- { matchesPath: (path) => path.startsWith("effects.") },
-];
-
-const PROPERTY_LABELS: Partial<Record<string, string>> = {
- "transform.positionX": "Position X",
- "transform.positionY": "Position Y",
- "transform.scaleX": "Scale X",
- "transform.scaleY": "Scale Y",
- "transform.rotate": "Rotation",
- opacity: "Opacity",
- volume: "Volume",
- color: "Color",
- "background.color": "BG Color",
- "background.paddingX": "BG Pad X",
- "background.paddingY": "BG Pad Y",
- "background.offsetX": "BG Offset X",
- "background.offsetY": "BG Offset Y",
- "background.cornerRadius": "Corner Radius",
-};
-
-export function getPropertyLabel(path: AnimationPath): string {
- if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path];
- if (path.startsWith("params.")) return path.slice("params.".length);
- if (path.startsWith("effects.")) {
- const parts = path.split(".");
- return parts[parts.length - 1];
- }
- return path;
-}
-
-export function getExpandedRows({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ExpandedRow[] {
- const keyframes = getElementKeyframes({ animations });
- const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))];
- if (propertyPaths.length === 0) return [];
-
- const rows: ExpandedRow[] = [];
-
- for (const group of PROPERTY_GROUPS) {
- const groupPaths = propertyPaths.filter((path) =>
- group.matchesPath(path),
- );
- for (const path of groupPaths) {
- rows.push({ propertyPath: path, label: getPropertyLabel(path) });
- }
- }
-
- return rows;
-}
-
-export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number {
- return rows.length * KEYFRAME_LANE_HEIGHT_PX;
-}
-
-export function computeTrackExpansionHeight({
- track,
- expandedElementIds,
-}: {
- track: TimelineTrack;
- expandedElementIds: Set<string>;
-}): number {
- let maxHeight = 0;
- for (const element of track.elements) {
- if (!expandedElementIds.has(element.id)) continue;
- const rows = getExpandedRows({ animations: element.animations });
- maxHeight = Math.max(maxHeight, getExpansionHeight({ rows }));
- }
- return maxHeight;
-}
-
-export function getTrackExpandedRows({
- track,
- expandedElementIds,
-}: {
- track: TimelineTrack;
- expandedElementIds: Set<string>;
-}): ExpandedRow[] {
- let maxHeight = 0;
- let maxRows: ExpandedRow[] = [];
-
- for (const element of track.elements) {
- if (!expandedElementIds.has(element.id)) continue;
- const rows = getExpandedRows({ animations: element.animations });
- const height = getExpansionHeight({ rows });
- if (height > maxHeight) {
- maxHeight = height;
- maxRows = rows;
- }
- }
-
- return maxRows;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
deleted file mode 100644
index 3f78350b..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-"use client";
-
-import { useRef, useState, type PointerEvent } from "react";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { getBezierPoint } from "@/lib/animation/bezier";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import { cn } from "@/utils/ui";
-
-const GRAPH_WIDTH = 140;
-const GRAPH_HEIGHT = 94;
-const GRAPH_PADDING = 12;
-const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
-const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
-const HANDLE_RADIUS = 3.5;
-const ENDPOINT_RADIUS = 2;
-const SNAP_THRESHOLD = 0.06;
-const SNAP_TARGETS = [0, 1];
-const CURVE_SEGMENTS = 64;
-const Y_CLAMP_MIN = -0.5;
-const Y_CLAMP_MAX = 1.5;
-
-type BezierHandle = "c1" | "c2";
-
-export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
-
-function snap({
- value,
- targets,
- isEnabled,
-}: {
- value: number;
- targets: number[];
- isEnabled: boolean;
-}) {
- if (!isEnabled) return value;
- for (const target of targets) {
- if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
- }
- return value;
-}
-
-function toSvgX({ value }: { value: number }) {
- return GRAPH_PADDING + value * GRAPH_WIDTH;
-}
-
-function toSvgY({ value }: { value: number }) {
- return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
-}
-
-function fromSvgX({ svgX }: { svgX: number }) {
- return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
-}
-
-function fromSvgY({ svgY }: { svgY: number }) {
- return Math.max(
- Y_CLAMP_MIN,
- Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
- );
-}
-
-function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
- const points: string[] = [];
- for (let i = 0; i <= CURVE_SEGMENTS; i++) {
- const progress = i / CURVE_SEGMENTS;
- const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
- const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
- points.push(`${x},${y}`);
- }
- return `M${points.join("L")}`;
-}
-
-function clampHandleY({ svgY }: { svgY: number }) {
- return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
-}
-
-export function BezierGraph({
- value,
- onChange,
- onChangeEnd,
- onCancel,
-}: {
- value: NormalizedCubicBezier;
- onChange?: (value: NormalizedCubicBezier) => void;
- onChangeEnd?: (value: NormalizedCubicBezier) => void;
- onCancel?: () => void;
-}) {
- const svgRef = useRef<SVGSVGElement>(null);
- const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
- const isShiftPressedRef = useShiftKey();
- const latestValueRef = useRef(value);
-
- latestValueRef.current = value;
-
- function getPointerPosition({
- event,
- }: {
- event: PointerEvent;
- }): { x: number; y: number } {
- const svg = svgRef.current;
- if (!svg) return { x: 0, y: 0 };
- const rect = svg.getBoundingClientRect();
- const scale = SVG_WIDTH / rect.width;
- return {
- x: (event.clientX - rect.left) * scale,
- y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
- };
- }
-
- function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
- return (event: PointerEvent<SVGCircleElement>) => {
- event.preventDefault();
- event.stopPropagation();
- setActiveHandle(handle);
- event.currentTarget.setPointerCapture(event.pointerId);
- };
- }
-
- function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
- if (!activeHandle) return;
- const pointerPos = getPointerPosition({ event });
- const x = fromSvgX({ svgX: pointerPos.x });
- const y = snap({
- value: fromSvgY({ svgY: pointerPos.y }),
- targets: SNAP_TARGETS,
- isEnabled: !isShiftPressedRef.current,
- });
- const next: NormalizedCubicBezier = [...value];
- if (activeHandle === "c1") {
- next[0] = x;
- next[1] = y;
- } else {
- next[2] = x;
- next[3] = y;
- }
- latestValueRef.current = next;
- onChange?.(next);
- }
-
- function onPointerUp() {
- if (!activeHandle) return;
- setActiveHandle(null);
- onChangeEnd?.(latestValueRef.current);
- }
-
- function onPointerCancel() {
- if (!activeHandle) return;
- setActiveHandle(null);
- onCancel?.();
- }
-
- const path = curvePath({ curve: value });
- const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
- const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
- const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
- const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
- const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
- const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
-
- return (
- <svg
- ref={svgRef}
- viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
- className="bg-foreground/3 w-full cursor-crosshair select-none"
- onPointerMove={(event) => onPointerMove({ event })}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerCancel}
- >
- <title>Bezier curve editor</title>
- <line
- x1={p0.x}
- y1={p0.y}
- x2={p1.x}
- y2={p1.y}
- className="stroke-foreground/8"
- strokeWidth={1}
- strokeDasharray="3 3"
- />
- <line
- x1={p0.x}
- y1={p0.y}
- x2={c1Clamped.x}
- y2={c1Clamped.y}
- className="stroke-primary/30"
- strokeWidth={1}
- />
- <line
- x1={p1.x}
- y1={p1.y}
- x2={c2Clamped.x}
- y2={c2Clamped.y}
- className="stroke-primary/30"
- strokeWidth={1}
- />
- <path
- d={path}
- fill="none"
- className="stroke-primary"
- strokeWidth={2}
- strokeLinecap="round"
- />
- <circle
- cx={p0.x}
- cy={p0.y}
- r={ENDPOINT_RADIUS}
- className="fill-foreground/20"
- />
- <circle
- cx={p1.x}
- cy={p1.y}
- r={ENDPOINT_RADIUS}
- className="fill-foreground/20"
- />
- <circle
- cx={c1Clamped.x}
- cy={c1Clamped.y}
- r={HANDLE_RADIUS}
- className={cn(
- "fill-primary cursor-grab",
- activeHandle === "c1" && "cursor-grabbing",
- )}
- onPointerDown={onHandlePointerDown({ handle: "c1" })}
- />
- <circle
- cx={c2Clamped.x}
- cy={c2Clamped.y}
- r={HANDLE_RADIUS}
- className={cn(
- "fill-primary cursor-grab",
- activeHandle === "c2" && "cursor-grabbing",
- )}
- onPointerDown={onHandlePointerDown({ handle: "c2" })}
- />
- </svg>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
deleted file mode 100644
index 684ac7c9..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { useSyncExternalStore } from "react";
-import { generateUUID } from "@/utils/id";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import type { EasingPreset } from "./easing-presets";
-
-const STORAGE_KEY = "graph-editor-presets";
-
-let cachedPresets: EasingPreset[] | null = null;
-const listeners = new Set<() => void>();
-
-function isValidPresetArray(value: unknown): value is EasingPreset[] {
- return (
- Array.isArray(value) &&
- value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- typeof item.id === "string" &&
- typeof item.label === "string" &&
- Array.isArray(item.value) &&
- item.value.length === 4 &&
- item.value.every((number: unknown) => typeof number === "number"),
- )
- );
-}
-
-function readFromStorage(): EasingPreset[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return [];
- const parsed: unknown = JSON.parse(raw);
- return isValidPresetArray(parsed) ? parsed : [];
- } catch {
- // Silently recover — corrupted localStorage shouldn't crash the editor
- return [];
- }
-}
-
-function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
-}
-
-function getSnapshot(): EasingPreset[] {
- cachedPresets ??= readFromStorage();
- return cachedPresets;
-}
-
-function getServerSnapshot(): EasingPreset[] {
- return [];
-}
-
-function notify(): void {
- cachedPresets = null;
- for (const listener of listeners) {
- listener();
- }
-}
-
-function onStorageChange(event: StorageEvent): void {
- if (event.key === STORAGE_KEY) notify();
-}
-
-function subscribe(listener: () => void): () => void {
- if (listeners.size === 0 && typeof window !== "undefined") {
- window.addEventListener("storage", onStorageChange);
- }
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- if (listeners.size === 0 && typeof window !== "undefined") {
- window.removeEventListener("storage", onStorageChange);
- }
- };
-}
-
-export function useCustomPresets(): EasingPreset[] {
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-}
-
-export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
- const current = getSnapshot();
- writeToStorage({
- presets: [
- ...current,
- {
- id: generateUUID(),
- label: `Custom ${current.length + 1}`,
- value,
- isCustom: true,
- },
- ],
- });
- notify();
-}
-
-export function removePreset({ id }: { id: string }): void {
- writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
- notify();
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
deleted file mode 100644
index 6ccc9071..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-
-export const PRESET_MATCH_TOLERANCE = 0.02;
-
-export interface EasingPreset {
- id: string;
- label: string;
- value: NormalizedCubicBezier;
- isCustom?: boolean;
-}
-
-export const BUILTIN_PRESETS: EasingPreset[] = [
- { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
- { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
- { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
- { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
- { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
- { id: "linear", label: "Linear", value: [0, 0, 1, 1] },
-];
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
deleted file mode 100644
index 733195ed..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
+++ /dev/null
@@ -1,334 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Popover, PopoverContent } from "@/components/ui/popover";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowDown01Icon,
- Delete02Icon,
- PlusSignIcon,
-} from "@hugeicons/core-free-icons";
-import { getBezierPoint } from "@/lib/animation/bezier";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import type { GraphEditorComponentOption } from "./session";
-import {
- BUILTIN_PRESETS,
- PRESET_MATCH_TOLERANCE,
- type EasingPreset,
-} from "./easing-presets";
-import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
-import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
-
-const COLLAPSED_MAX = 6;
-const THUMB_SEGMENTS = 24;
-const THUMB_WIDTH = 40;
-const THUMB_HEIGHT = 22;
-const THUMB_PADDING_X = 4;
-const THUMB_PADDING_Y = 3;
-const COLLAPSED_GRID_MAX_HEIGHT = 120;
-const EXPANDED_GRID_MAX_HEIGHT = 240;
-
-export function GraphEditorPopover({
- children,
- side,
- open,
- onOpenChange,
- value,
- message,
- componentOptions,
- activeComponentKey,
- onActiveComponentKeyChange,
- onPreviewValue,
- onCommitValue,
- onCancelPreview,
-}: {
- children: React.ReactNode;
- side?: "top" | "bottom" | "left" | "right";
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- value: NormalizedCubicBezier | null;
- message: string;
- componentOptions: GraphEditorComponentOption[];
- activeComponentKey: string | null;
- onActiveComponentKeyChange?: (componentKey: string) => void;
- onPreviewValue?: (value: NormalizedCubicBezier) => void;
- onCommitValue?: (value: NormalizedCubicBezier) => void;
- onCancelPreview?: () => void;
-}) {
- const [isExpanded, setIsExpanded] = useState(false);
- const custom = useCustomPresets();
- const allPresets = [...BUILTIN_PRESETS, ...custom];
- const canEdit = value !== null;
- const activePresetId =
- value == null
- ? null
- : (allPresets.find((preset) =>
- preset.value.every(
- (presetValue, index) =>
- Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
- ),
- )?.id ?? null);
-
- return (
- <Popover
- open={open}
- onOpenChange={(nextOpen) => {
- if (!nextOpen) {
- onCancelPreview?.();
- }
- onOpenChange?.(nextOpen);
- }}
- >
- {children}
- <PopoverContent
- side={side}
- sideOffset={8}
- className="w-60 overflow-hidden px-0"
- >
- {componentOptions.length > 1 && (
- <div className="border-b px-3 py-2">
- <div className="bg-muted/40 inline-flex rounded-md p-0.5">
- {componentOptions.map((component) => (
- <button
- key={component.key}
- type="button"
- onClick={() => onActiveComponentKeyChange?.(component.key)}
- className={cn(
- "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
- activeComponentKey === component.key
- ? "bg-background text-foreground shadow-xs"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
- {component.label}
- </button>
- ))}
- </div>
- </div>
- )}
-
- <div className="px-3 py-3">
- {value ? (
- <BezierGraph
- value={value}
- onChange={onPreviewValue}
- onChangeEnd={onCommitValue}
- onCancel={onCancelPreview}
- />
- ) : (
- <GraphEditorEmptyState message={message} />
- )}
- </div>
-
- <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
- <TabsList className="px-3">
- <TabsTrigger value="presets" className="text-xs">
- Presets
- </TabsTrigger>
- <TabsTrigger value="saved" className="text-xs">
- Saved
- </TabsTrigger>
- </TabsList>
- <TabsContent value="presets" className="px-3 pb-0">
- <ExpandableGrid
- isExpanded={isExpanded}
- shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
- onExpand={() => setIsExpanded(true)}
- >
- {BUILTIN_PRESETS.map((preset) => (
- <PresetItem
- key={preset.id}
- preset={preset}
- isActive={activePresetId === preset.id}
- disabled={!canEdit}
- onSelect={() => onCommitValue?.(preset.value)}
- />
- ))}
- </ExpandableGrid>
- </TabsContent>
- <TabsContent value="saved" className="px-3">
- <div className="grid grid-cols-3 gap-1">
- {custom.map((preset) => (
- <PresetItem
- key={preset.id}
- preset={preset}
- isActive={activePresetId === preset.id}
- disabled={!canEdit}
- onSelect={() => onCommitValue?.(preset.value)}
- onDelete={() => removePreset({ id: preset.id })}
- />
- ))}
- <button
- type="button"
- onClick={() => value && savePreset({ value })}
- disabled={!canEdit}
- className={cn(
- "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
- canEdit
- ? "hover:bg-foreground/5 cursor-pointer"
- : "cursor-not-allowed opacity-50",
- )}
- >
- <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
- <HugeiconsIcon
- icon={PlusSignIcon}
- className="size-3.5 opacity-40"
- />
- </div>
- <span className="text-[10px] leading-tight">Save</span>
- </button>
- </div>
- </TabsContent>
- </Tabs>
- </PopoverContent>
- </Popover>
- );
-}
-
-function GraphEditorEmptyState({ message }: { message: string }) {
- return (
- <div
- style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
- className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
- >
- {message}
- </div>
- );
-}
-
-function ExpandableGrid({
- children,
- isExpanded,
- shouldExpand,
- onExpand,
-}: {
- children: React.ReactNode;
- isExpanded: boolean;
- shouldExpand: boolean;
- onExpand: () => void;
-}) {
- const gridStyle = shouldExpand
- ? isExpanded
- ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
- : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
- : undefined;
-
- return (
- <div className="relative">
- <div className="grid grid-cols-3 gap-1" style={gridStyle}>
- {children}
- </div>
- {!isExpanded && shouldExpand && (
- <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
- <Button
- variant="ghost"
- size="icon"
- className="size-5"
- onClick={onExpand}
- >
- <HugeiconsIcon
- icon={ArrowDown01Icon}
- className="text-muted-foreground size-3"
- />
- </Button>
- </div>
- )}
- </div>
- );
-}
-
-function PresetItem({
- preset,
- isActive,
- onSelect,
- onDelete,
- disabled,
-}: {
- preset: EasingPreset;
- isActive: boolean;
- onSelect: () => void;
- onDelete?: () => void;
- disabled?: boolean;
-}) {
- return (
- <button
- type="button"
- onClick={onSelect}
- disabled={disabled}
- className={cn(
- "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
- disabled
- ? "cursor-not-allowed opacity-50"
- : "hover:bg-foreground/5 cursor-pointer",
- isActive && "bg-primary/5! text-primary",
- )}
- >
- <div
- className={cn(
- "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
- isActive && "bg-primary/5!",
- )}
- >
- <CurveThumb value={preset.value} />
- </div>
- <span
- className={cn(
- "text-[10px] leading-tight",
- isActive ? "text-primary" : "text-muted-foreground",
- )}
- >
- {preset.label}
- </span>
- {onDelete && (
- <Button
- variant="destructive"
- size="icon"
- className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
- onClick={(event) => {
- event.stopPropagation();
- onDelete();
- }}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- )}
- </button>
- );
-}
-
-function toThumbX({ value }: { value: number }) {
- return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2);
-}
-
-function toThumbY({ value }: { value: number }) {
- return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2);
-}
-
-function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
- const points: string[] = [];
- for (let i = 0; i <= THUMB_SEGMENTS; i++) {
- const progress = i / THUMB_SEGMENTS;
- const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) });
- const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) });
- points.push(`${x},${y}`);
- }
- return (
- <svg
- width={THUMB_WIDTH}
- height={THUMB_HEIGHT}
- viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
- >
- <title>Curve preset preview</title>
- <path
- d={`M${points.join("L")}`}
- fill="none"
- className="stroke-current"
- strokeWidth={1.5}
- strokeLinecap="round"
- />
- </svg>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
deleted file mode 100644
index cc0fb070..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ /dev/null
@@ -1,704 +0,0 @@
-import {
- getCurveHandlesForNormalizedCubicBezier,
- getEditableScalarChannels,
- getEasingModeForKind,
- getNormalizedCubicBezierForScalarSegment,
- getScalarKeyframeContext,
- updateScalarKeyframeCurve,
-} from "@/lib/animation";
-import type {
- AnimationPath,
- ElementAnimations,
- NormalizedCubicBezier,
- ScalarCurveKeyframePatch,
- ScalarGraphKeyframeContext,
- SelectedKeyframeRef,
-} from "@/lib/animation/types";
-import type { SceneTracks, TimelineElement } from "@/lib/timeline";
-
-const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
-const FLAT_VALUE_EPSILON = 1e-6;
-const LINEAR_CURVE_EPSILON = 1e-6;
-
-export type GraphEditorUnavailableReason =
- | "no-keyframe-selected"
- | "multiple-keyframes-selected"
- | "selected-keyframes-span-multiple-elements"
- | "selected-keyframes-are-not-adjacent"
- | "selected-properties-have-no-shared-component"
- | "selected-element-missing"
- | "selected-element-has-no-animations"
- | "selected-keyframe-has-no-scalar-channel"
- | "selected-keyframe-missing-on-channel"
- | "selected-keyframe-has-no-next-segment"
- | "selected-segment-is-hold"
- | "selected-segment-is-flat";
-
-export interface GraphEditorComponentOption {
- key: string;
- label: string;
-}
-
-interface GraphEditorPropertyOption {
- key: string;
- label: string;
- context: ScalarGraphKeyframeContext;
- allContexts: ScalarGraphKeyframeContext[];
-}
-
-export interface GraphEditorResolvedSegment {
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframeId: string;
- context: ScalarGraphKeyframeContext;
- allContexts: ScalarGraphKeyframeContext[];
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}
-
-interface GraphEditorBaseSelectionState {
- componentOptions: GraphEditorComponentOption[];
- activeComponentKey: string | null;
- message: string;
-}
-
-export interface GraphEditorUnavailableState
- extends GraphEditorBaseSelectionState {
- status: "unavailable";
- reason: GraphEditorUnavailableReason;
-}
-
-export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
- status: "ready";
- trackId: string;
- elementId: string;
- element: TimelineElement;
- segments: GraphEditorResolvedSegment[];
- cubicBezier: NormalizedCubicBezier;
-}
-
-export type GraphEditorSelectionState =
- | GraphEditorUnavailableState
- | GraphEditorReadyState;
-
-export interface GraphEditorCurvePatch {
- keyframeId: string;
- patch: ScalarCurveKeyframePatch;
-}
-
-function createUnavailableState({
- reason,
- message,
- componentOptions = [],
- activeComponentKey = null,
-}: {
- reason: GraphEditorUnavailableReason;
- message: string;
- componentOptions?: GraphEditorComponentOption[];
- activeComponentKey?: string | null;
-}): GraphEditorUnavailableState {
- return {
- status: "unavailable",
- reason,
- message,
- componentOptions,
- activeComponentKey,
- };
-}
-
-function findElementByKeyframe({
- tracks,
- keyframe,
-}: {
- tracks: SceneTracks;
- keyframe: SelectedKeyframeRef;
-}): { element: TimelineElement; trackId: string; elementId: string } | null {
- for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
- if (track.id !== keyframe.trackId) {
- continue;
- }
-
- const element = track.elements.find(
- (trackElement) => trackElement.id === keyframe.elementId,
- );
- if (!element) {
- return null;
- }
-
- return {
- element,
- trackId: track.id,
- elementId: element.id,
- };
- }
-
- return null;
-}
-
-function findKeyframeTime({
- animations,
- propertyPath,
- keyframeId,
-}: {
- animations: ElementAnimations;
- propertyPath: AnimationPath;
- keyframeId: string;
-}): number | null {
- const binding = animations.bindings[propertyPath];
- if (!binding) return null;
-
- for (const component of binding.components) {
- const channel = animations.channels[component.channelId];
- if (channel?.kind !== "scalar") continue;
- const key = channel.keys.find((k) => k.id === keyframeId);
- if (key !== undefined) return key.time;
- }
-
- return null;
-}
-
-function groupSelectedKeyframesByProperty({
- selectedKeyframes,
-}: {
- selectedKeyframes: SelectedKeyframeRef[];
-}) {
- const groups = new Map<
- string,
- {
- trackId: string;
- elementId: string;
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframes: SelectedKeyframeRef[];
- }
- >();
-
- for (const keyframe of selectedKeyframes) {
- const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
- const existingGroup = groups.get(groupKey);
- if (existingGroup) {
- existingGroup.keyframes.push(keyframe);
- continue;
- }
-
- groups.set(groupKey, {
- trackId: keyframe.trackId,
- elementId: keyframe.elementId,
- propertyPath: keyframe.propertyPath,
- keyframes: [keyframe],
- });
- }
-
- return [...groups.values()];
-}
-
-function getComponentLabel({ componentKey }: { componentKey: string }): string {
- switch (componentKey) {
- case "value":
- return "Value";
- default:
- return componentKey.toUpperCase();
- }
-}
-
-/**
- * Returns the absolute value span of the nearest non-flat adjacent segment,
- * used as the Y-axis scale when editing a flat segment in the graph editor.
- * Falls back to 1.0 if all surrounding segments are also flat.
- */
-function getReferenceSpanValue({
- context,
-}: {
- context: ScalarGraphKeyframeContext;
-}): number {
- const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
- const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id);
- const rightIndex = context.nextKey
- ? sorted.findIndex((k) => k.id === context.nextKey?.id)
- : -1;
-
- for (let i = leftIndex - 1; i >= 0; i--) {
- const span = Math.abs(sorted[i + 1].value - sorted[i].value);
- if (span > FLAT_VALUE_EPSILON) return span;
- }
-
- if (rightIndex !== -1) {
- for (let i = rightIndex; i < sorted.length - 1; i++) {
- const span = Math.abs(sorted[i + 1].value - sorted[i].value);
- if (span > FLAT_VALUE_EPSILON) return span;
- }
- }
-
- return 1.0;
-}
-
-interface GraphEditorPropertySelection {
- propertyPath: SelectedKeyframeRef["propertyPath"];
- keyframeId: string;
- secondaryKeyframeId: string | null;
- options: GraphEditorPropertyOption[];
-}
-
-function resolvePropertySelection({
- element,
- propertyKeyframes,
-}: {
- element: TimelineElement;
- propertyKeyframes: ReturnType<
- typeof groupSelectedKeyframesByProperty
- >[number];
-}):
- | GraphEditorPropertySelection
- | {
- reason: GraphEditorUnavailableReason;
- message: string;
- } {
- if (propertyKeyframes.keyframes.length > 2) {
- return {
- reason: "multiple-keyframes-selected",
- message: "Select at most two adjacent keyframes per property.",
- };
- }
-
- if (!element.animations) {
- return {
- reason: "selected-element-has-no-animations",
- message: "The selected keyframe has no editable graph.",
- };
- }
-
- const scalarResult = getEditableScalarChannels({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- });
- if (!scalarResult || scalarResult.channels.length === 0) {
- return {
- reason: "selected-keyframe-has-no-scalar-channel",
- message: "The selected keyframe has no editable graph channel.",
- };
- }
-
- const primaryKeyframe = propertyKeyframes.keyframes[0];
- let resolvedKeyframeId = primaryKeyframe.keyframeId;
- let secondaryKeyframeId =
- propertyKeyframes.keyframes.length === 2
- ? propertyKeyframes.keyframes[1].keyframeId
- : null;
-
- if (secondaryKeyframeId !== null) {
- const time1 = findKeyframeTime({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: primaryKeyframe.keyframeId,
- });
- const time2 = findKeyframeTime({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: secondaryKeyframeId,
- });
- if (time2 !== null && (time1 === null || time2 < time1)) {
- resolvedKeyframeId = secondaryKeyframeId;
- secondaryKeyframeId = primaryKeyframe.keyframeId;
- }
- }
-
- const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
- const easingMode = getEasingModeForKind(resolvedBinding.kind);
- const contexts = scalarChannels.flatMap((channel) => {
- const context = getScalarKeyframeContext({
- animations: element.animations,
- propertyPath: propertyKeyframes.propertyPath,
- componentKey: channel.componentKey,
- keyframeId: resolvedKeyframeId,
- });
- if (!context) {
- return [];
- }
-
- return [
- {
- context,
- option: {
- key: channel.componentKey,
- label: getComponentLabel({ componentKey: channel.componentKey }),
- },
- },
- ];
- });
-
- if (contexts.length === 0) {
- return {
- reason: "selected-keyframe-missing-on-channel",
- message: "The selected keyframe is not editable as a graph segment.",
- };
- }
-
- // For shared-easing bindings (e.g. color), all components always use the same
- // curve. Collapse them to a single "value" option so the key is compatible with
- // single-component scalar bindings (e.g. opacity), enabling mixed selections.
- const options =
- easingMode === "shared"
- ? [
- {
- key: "value",
- label: "Curve",
- context: contexts[0].context,
- allContexts: contexts.map(({ context }) => context),
- },
- ]
- : contexts.map(({ context, option }) => ({
- key: option.key,
- label: option.label,
- context,
- allContexts: [context],
- }));
-
- return {
- propertyPath: propertyKeyframes.propertyPath,
- keyframeId: resolvedKeyframeId,
- secondaryKeyframeId,
- options,
- };
-}
-
-function isLinearCurve({
- cubicBezier,
-}: {
- cubicBezier: NormalizedCubicBezier;
-}): boolean {
- return (
- Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
- Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
- );
-}
-
-function resolveSegmentForOption({
- propertySelection,
- componentKey,
-}: {
- propertySelection: GraphEditorPropertySelection;
- componentKey: string;
-}):
- | {
- segment: GraphEditorResolvedSegment;
- }
- | {
- reason: GraphEditorUnavailableReason;
- message: string;
- } {
- const option = propertySelection.options.find(
- (propertyOption) => propertyOption.key === componentKey,
- );
- if (!option) {
- return {
- reason: "selected-properties-have-no-shared-component",
- message: "Selected properties do not share a graph-editable channel.",
- };
- }
-
- if (!option.context.nextKey) {
- return {
- reason: "selected-keyframe-has-no-next-segment",
- message: "Select a keyframe that has an outgoing segment.",
- };
- }
-
- if (
- propertySelection.secondaryKeyframeId !== null &&
- option.context.nextKey.id !== propertySelection.secondaryKeyframeId
- ) {
- return {
- reason: "selected-keyframes-are-not-adjacent",
- message: "Selected keyframes must be adjacent on each property.",
- };
- }
-
- if (option.context.keyframe.segmentToNext === "step") {
- return {
- reason: "selected-segment-is-hold",
- message: "Hold segments have a fixed value - easing has no effect here.",
- };
- }
-
- const referenceSpanValue = getReferenceSpanValue({ context: option.context });
- const cubicBezier =
- option.context.keyframe.segmentToNext === "linear"
- ? GRAPH_LINEAR_CURVE
- : getNormalizedCubicBezierForScalarSegment({
- leftKey: option.context.keyframe,
- rightKey: option.context.nextKey,
- referenceSpanValue,
- });
- if (!cubicBezier) {
- return {
- reason: "selected-segment-is-flat",
- message:
- "Cannot edit a segment where both keyframes are at the same time.",
- };
- }
-
- return {
- segment: {
- propertyPath: propertySelection.propertyPath,
- keyframeId: propertySelection.keyframeId,
- context: option.context,
- allContexts: option.allContexts,
- cubicBezier,
- referenceSpanValue,
- },
- };
-}
-
-export function resolveGraphEditorSelectionState({
- tracks,
- selectedKeyframes,
- preferredComponentKey,
-}: {
- tracks: SceneTracks;
- selectedKeyframes: SelectedKeyframeRef[];
- preferredComponentKey?: string | null;
-}): GraphEditorSelectionState {
- if (selectedKeyframes.length === 0) {
- return createUnavailableState({
- reason: "no-keyframe-selected",
- message: "Select a keyframe to edit its curve.",
- });
- }
-
- const propertyKeyframes = groupSelectedKeyframesByProperty({
- selectedKeyframes,
- });
- const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
- if (!primaryKeyframe) {
- return createUnavailableState({
- reason: "no-keyframe-selected",
- message: "Select a keyframe to edit its curve.",
- });
- }
-
- const selectedElement = findElementByKeyframe({
- tracks,
- keyframe: primaryKeyframe,
- });
- if (!selectedElement) {
- return createUnavailableState({
- reason: "selected-element-missing",
- message: "The selected keyframe could not be resolved.",
- });
- }
-
- const spansMultipleElements = propertyKeyframes.some(
- (propertySelection) =>
- propertySelection.trackId !== selectedElement.trackId ||
- propertySelection.elementId !== selectedElement.elementId,
- );
- if (spansMultipleElements) {
- return createUnavailableState({
- reason: "selected-keyframes-span-multiple-elements",
- message: "Selected keyframes must be on the same element.",
- });
- }
-
- const propertySelections = propertyKeyframes.map((propertySelection) =>
- resolvePropertySelection({
- element: selectedElement.element,
- propertyKeyframes: propertySelection,
- }),
- );
- const unavailablePropertySelection = propertySelections.find(
- (propertySelection) => "reason" in propertySelection,
- );
- if (
- unavailablePropertySelection &&
- "reason" in unavailablePropertySelection
- ) {
- return createUnavailableState({
- reason: unavailablePropertySelection.reason,
- message: unavailablePropertySelection.message,
- });
- }
-
- const resolvedPropertySelections = propertySelections.filter(
- (propertySelection): propertySelection is GraphEditorPropertySelection =>
- !("reason" in propertySelection),
- );
- const sharedComponentOptions =
- resolvedPropertySelections[0]?.options.filter((componentOption) =>
- resolvedPropertySelections.every((propertySelection) =>
- propertySelection.options.some(
- (option) => option.key === componentOption.key,
- ),
- ),
- ) ?? [];
- const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
- key,
- label,
- }));
- if (componentOptions.length === 0) {
- return createUnavailableState({
- reason: "selected-properties-have-no-shared-component",
- message: "Selected properties do not share a graph-editable channel.",
- });
- }
-
- // Try each component option in preference order (preferred first, then the rest)
- // and stop at the first key where every property resolves to a valid segment.
- // This single pass both selects the active key and produces the segment list.
- const candidateKeys = [
- ...(preferredComponentKey &&
- componentOptions.some((option) => option.key === preferredComponentKey)
- ? [preferredComponentKey]
- : []),
- ...componentOptions
- .filter((option) => option.key !== preferredComponentKey)
- .map((option) => option.key),
- ];
-
- let activeComponentKey = componentOptions[0].key;
- let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
-
- for (const candidateKey of candidateKeys) {
- const results = resolvedPropertySelections.map((propertySelection) =>
- resolveSegmentForOption({
- propertySelection,
- componentKey: candidateKey,
- }),
- );
- activeComponentKey = candidateKey;
- segmentResults = results;
- if (results.every((result) => "segment" in result)) {
- break;
- }
- }
-
- const unavailableSegment = segmentResults.find(
- (result) => !("segment" in result),
- );
- if (unavailableSegment && !("segment" in unavailableSegment)) {
- return createUnavailableState({
- reason: unavailableSegment.reason,
- message: unavailableSegment.message,
- componentOptions,
- activeComponentKey,
- });
- }
-
- const segments = segmentResults.flatMap((result) =>
- "segment" in result ? [result.segment] : [],
- );
- const primarySegment = segments[0];
- if (!primarySegment) {
- return createUnavailableState({
- reason: "selected-keyframe-missing-on-channel",
- message: "The selected keyframe is not editable as a graph segment.",
- componentOptions,
- activeComponentKey,
- });
- }
-
- return {
- status: "ready",
- message:
- segments.length === 1
- ? "Edit graph"
- : `Edit graph for ${segments.length} properties`,
- componentOptions,
- activeComponentKey,
- trackId: selectedElement.trackId,
- elementId: selectedElement.elementId,
- element: selectedElement.element,
- segments,
- cubicBezier: primarySegment.cubicBezier,
- };
-}
-
-export function buildGraphEditorCurvePatches({
- context,
- cubicBezier,
- referenceSpanValue,
-}: {
- context: ScalarGraphKeyframeContext;
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}): GraphEditorCurvePatch[] | null {
- if (!context.nextKey) {
- return null;
- }
-
- if (isLinearCurve({ cubicBezier })) {
- return [
- {
- keyframeId: context.keyframe.id,
- patch: {
- segmentToNext: "linear",
- rightHandle: null,
- },
- },
- {
- keyframeId: context.nextKey.id,
- patch: {
- leftHandle: null,
- },
- },
- ];
- }
-
- const handles = getCurveHandlesForNormalizedCubicBezier({
- leftKey: context.keyframe,
- rightKey: context.nextKey,
- cubicBezier,
- referenceSpanValue,
- });
- if (!handles) {
- return null;
- }
-
- return [
- {
- keyframeId: context.keyframe.id,
- patch: {
- segmentToNext: "bezier",
- rightHandle: handles.rightHandle,
- },
- },
- {
- keyframeId: context.nextKey.id,
- patch: {
- leftHandle: handles.leftHandle,
- },
- },
- ];
-}
-
-export function applyGraphEditorCurvePreview({
- animations,
- context,
- cubicBezier,
- referenceSpanValue,
-}: {
- animations: ElementAnimations | undefined;
- context: ScalarGraphKeyframeContext;
- cubicBezier: NormalizedCubicBezier;
- referenceSpanValue: number;
-}): ElementAnimations | undefined {
- const patches = buildGraphEditorCurvePatches({
- context,
- cubicBezier,
- referenceSpanValue,
- });
- if (!patches) {
- return animations;
- }
-
- return patches.reduce<ElementAnimations | undefined>(
- (nextAnimations, { keyframeId, patch }) =>
- updateScalarKeyframeCurve({
- animations: nextAnimations,
- propertyPath: context.propertyPath,
- componentKey: context.componentKey,
- keyframeId,
- patch,
- }),
- animations,
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
deleted file mode 100644
index 51d5a0ff..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { registerCanceller } from "@/lib/cancel-interaction";
-import type { NormalizedCubicBezier } from "@/lib/animation/types";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import {
- applyGraphEditorCurvePreview,
- buildGraphEditorCurvePatches,
- resolveGraphEditorSelectionState,
- type GraphEditorSelectionState,
-} from "./session";
-
-export function useGraphEditorController() {
- const editor = useEditor();
- const renderTracks = useEditor(
- (currentEditor) =>
- currentEditor.timeline.getPreviewTracks() ??
- currentEditor.scenes.getActiveScene().tracks,
- );
- const { selectedKeyframes } = useKeyframeSelection();
- const [open, setOpen] = useState(false);
- const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
- null,
- );
- const hasPreviewRef = useRef(false);
-
- const state = useMemo<GraphEditorSelectionState>(
- () =>
- resolveGraphEditorSelectionState({
- tracks: renderTracks,
- selectedKeyframes,
- preferredComponentKey: activeComponentKey,
- }),
- [activeComponentKey, renderTracks, selectedKeyframes],
- );
-
- const stateKey =
- state.status === "ready"
- ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments
- .map(
- (segment) =>
- `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`,
- )
- .join("|")}`
- : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
- const previousStateKeyRef = useRef(stateKey);
-
- const discardPreview = useCallback(() => {
- if (!hasPreviewRef.current) {
- return;
- }
-
- editor.timeline.discardPreview();
- hasPreviewRef.current = false;
- }, [editor]);
-
- useEffect(() => {
- if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
- discardPreview();
- }
-
- previousStateKeyRef.current = stateKey;
- }, [discardPreview, stateKey]);
-
- useEffect(() => {
- if (!open) {
- return;
- }
-
- return registerCanceller({
- fn: () => {
- discardPreview();
- setOpen(false);
- },
- });
- }, [discardPreview, open]);
-
- const handleOpenChange = useCallback(
- (nextOpen: boolean) => {
- if (!nextOpen) {
- discardPreview();
- }
-
- setOpen(nextOpen);
- },
- [discardPreview],
- );
-
- const handleActiveComponentKeyChange = useCallback(
- (nextComponentKey: string) => {
- discardPreview();
- setActiveComponentKey(nextComponentKey);
- },
- [discardPreview],
- );
-
- const handlePreviewValue = useCallback(
- (nextValue: NormalizedCubicBezier) => {
- if (state.status !== "ready") {
- return;
- }
-
- const nextAnimations = state.segments.reduce(
- (animations, segment) =>
- segment.allContexts.reduce(
- (nextAnimationsForSegment, context) =>
- applyGraphEditorCurvePreview({
- animations: nextAnimationsForSegment,
- context,
- cubicBezier: nextValue,
- referenceSpanValue: segment.referenceSpanValue,
- }),
- animations,
- ),
- state.element.animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId: state.trackId,
- elementId: state.elementId,
- updates: { animations: nextAnimations },
- },
- ],
- });
- hasPreviewRef.current = true;
- },
- [editor, state],
- );
-
- const handleCommitValue = useCallback(
- (nextValue: NormalizedCubicBezier) => {
- if (state.status !== "ready") {
- return;
- }
-
- editor.timeline.updateKeyframeCurves({
- keyframes: state.segments.flatMap((segment) => {
- const patches = buildGraphEditorCurvePatches({
- context: segment.context,
- cubicBezier: nextValue,
- referenceSpanValue: segment.referenceSpanValue,
- });
- if (!patches) {
- return [];
- }
-
- return segment.allContexts.flatMap((context) =>
- patches.map(({ keyframeId, patch }) => ({
- trackId: state.trackId,
- elementId: state.elementId,
- propertyPath: segment.propertyPath,
- componentKey: context.componentKey,
- keyframeId,
- patch,
- })),
- );
- }),
- });
- hasPreviewRef.current = false;
- },
- [editor, state],
- );
-
- return {
- open,
- onOpenChange: handleOpenChange,
- canOpen: state.status === "ready",
- tooltip: state.status === "ready" ? "Open graph editor" : state.message,
- state,
- onActiveComponentKeyChange: handleActiveComponentKeyChange,
- onPreviewValue: handlePreviewValue,
- onCommitValue: handleCommitValue,
- onCancelPreview: discardPreview,
- };
-}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
deleted file mode 100644
index 20e9faf1..00000000
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ /dev/null
@@ -1,944 +0,0 @@
-"use client";
-
-import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- Delete02Icon,
- MagicWand05Icon,
- MusicNote03Icon,
- TaskAdd02Icon,
- TextIcon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
-import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
-import type { ElementDragState, DropTarget } from "@/lib/timeline";
-import { TimelineTrackContent } from "./timeline-track";
-import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "@/lib/selection/selection-box";
-import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
-import { SnapIndicator } from "./snap-indicator";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import type { TimelineTrack } from "@/lib/timeline";
-import {
- TIMELINE_SCROLLBAR_SIZE_PX,
- TIMELINE_CONTENT_TOP_PADDING_PX,
- TIMELINE_TRACK_GAP_PX,
- TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
- KEYFRAME_LANE_HEIGHT_PX,
-} from "./layout";
-import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
-import {
- canTrackHaveAudio,
- canTrackBeHidden,
- getTimelineZoomMin,
- getTimelinePaddingPx,
-} from "@/lib/timeline";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
-} from "./track-layout";
-import { SELECTED_TRACK_ROW_CLASS } from "./theme";
-import {
- computeTrackExpansionHeight,
- getTrackExpandedRows,
- getPropertyLabel,
- type ExpandedRow,
-} from "./expanded-layout";
-import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
-import { TimelineToolbar } from "./timeline-toolbar";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
-import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
-import { TimelineRuler } from "./timeline-ruler";
-import { TimelineBookmarksRow } from "./bookmarks";
-import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
-import { useTimelineResize } from "@/hooks/timeline/use-timeline-resize";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useEditor } from "@/hooks/use-editor";
-import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
-import { DragLine } from "./drag-line";
-import { invokeAction } from "@/lib/actions";
-import { resolveTimelineElementIntersections } from "./selection-hit-testing";
-import { cn } from "@/utils/ui";
-
-const TRACKS_CONTAINER_MAX_HEIGHT = 800;
-const FALLBACK_CONTAINER_WIDTH = 1000;
-const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
-const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = {
- video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
- text: (
- <HugeiconsIcon
- icon={TextIcon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- audio: (
- <HugeiconsIcon
- icon={MusicNote03Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
- effect: (
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
-};
-
-export function Timeline() {
- const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const {
- selectedElements,
- clearElementSelection,
- setElementSelection,
- mergeElementsIntoSelection,
- } = useElementSelection();
- const editor = useEditor();
- const timeline = editor.timeline;
- const scene = useEditor((currentEditor) =>
- currentEditor.scenes.getActiveSceneOrNull(),
- );
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const mainTrackId = scene?.tracks.main.id ?? null;
- const seek = (time: number) => editor.playback.seek({ time });
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const timelineHeaderRef = useRef<HTMLDivElement>(null);
- const rulerRef = useRef<HTMLDivElement>(null);
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const tracksContainerRef = useRef<HTMLDivElement>(null);
- const tracksScrollRef = useRef<HTMLDivElement>(null);
- const trackLabelsRef = useRef<HTMLDivElement>(null);
- const playheadRef = useRef<HTMLDivElement>(null);
- const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
-
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null,
- );
-
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
-
- const timelineDuration = timeline.getTotalDuration() || 0;
- const minZoomLevel = getTimelineZoomMin({
- duration: timelineDuration,
- containerWidth: tracksContainerRef.current?.clientWidth,
- });
-
- const savedViewState = editor.project.getTimelineViewState();
-
- const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
- useTimelineZoom({
- containerRef: timelineRef,
- minZoom: minZoomLevel,
- initialZoom: savedViewState?.zoomLevel,
- initialScrollLeft: savedViewState?.scrollLeft,
- initialPlayheadTime: savedViewState?.playheadTime,
- tracksScrollRef,
- rulerScrollRef,
- });
- const { isResizing, handleResizeStart } = useTimelineResize({
- zoomLevel,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
-
- const getTrackExpansionHeight = useCallback(
- (trackIndex: number) => {
- const track = tracks[trackIndex];
- if (!track) return 0;
- return computeTrackExpansionHeight({ track, expandedElementIds });
- },
- [tracks, expandedElementIds],
- );
-
- // Stable refs so the wheel listener never goes stale
- const setZoomLevelRef = useRef(setZoomLevel);
- useEffect(() => {
- setZoomLevelRef.current = setZoomLevel;
- }, [setZoomLevel]);
-
- const saveScrollPositionRef = useRef(saveScrollPosition);
- useEffect(() => {
- saveScrollPositionRef.current = saveScrollPosition;
- }, [saveScrollPosition]);
-
- const minZoomLevelRef = useRef(minZoomLevel);
- useEffect(() => {
- minZoomLevelRef.current = minZoomLevel;
- }, [minZoomLevel]);
-
- // Pushes tracks scroll position to the two overflow:hidden followers
- // (ruler and track labels). Called from the wheel handler (before paint,
- // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
- const syncFollowers = useCallback(() => {
- const tracks = tracksScrollRef.current;
- if (!tracks) return;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
- }
- if (trackLabelsScrollRef.current) {
- trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
- }
- }, []);
-
- // Single non-passive capture listener owns all wheel input. Prevents any
- // native scroll or browser zoom from firing inside the timeline.
- useEffect(() => {
- const container = timelineRef.current;
- if (!container) return;
-
- let pendingZoomDelta = 0;
- let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
-
- const onWheel = (e: WheelEvent) => {
- const isZoom = e.ctrlKey || e.metaKey;
-
- if (isZoom) {
- e.preventDefault();
- const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
- pendingZoomDelta += normalizedDelta;
-
- if (zoomRafId === null) {
- zoomRafId = requestAnimationFrame(() => {
- const frameRawDelta = pendingZoomDelta;
- const cappedDelta =
- Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
- setZoomLevelRef.current((prev) => prev * zoomFactor);
- pendingZoomDelta = 0;
- zoomRafId = null;
- });
- }
- return;
- }
-
- const tracks = tracksScrollRef.current;
- if (!tracks) return;
-
- const isHorizontal =
- e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
-
- e.preventDefault();
-
- if (isHorizontal) {
- const raw =
- Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
- const clamped =
- Math.sign(raw) *
- Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
- tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
- } else {
- tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
- }
-
- syncFollowers();
- saveScrollPositionRef.current();
- };
-
- container.addEventListener("wheel", onWheel, {
- passive: false,
- capture: true,
- });
- return () => {
- container.removeEventListener("wheel", onWheel, { capture: true });
- if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
- };
- }, [syncFollowers]);
-
- useInitialScrollBottom({
- tracksScrollRef,
- trackLabelsScrollRef,
- onAfterScroll: () => saveScrollPositionRef.current(),
- isReady: tracks.length > 0,
- });
-
- const {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- } = useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const {
- dragState: bookmarkDragState,
- handleBookmarkMouseDown,
- lastMouseXRef: bookmarkLastMouseXRef,
- } = useBookmarkDrag({
- zoomLevel,
- scrollRef: tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
-
- const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
- useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
- containerRef: tracksContainerRef,
- tracksScrollRef,
- zoomLevel,
- });
-
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- shouldIgnoreClick,
- } = useBoxSelect({
- containerRef: tracksContainerRef,
- selectedIds: selectedElements,
- anchorId: null,
- getIsAdditiveSelection: (event) =>
- event.shiftKey || event.ctrlKey || event.metaKey,
- resolveIntersections: ({ startPos, currentPos }) => {
- if (!tracksContainerRef.current) {
- return [];
- }
-
- return resolveTimelineElementIntersections({
- container: tracksContainerRef.current,
- scrollContainer: tracksScrollRef.current,
- tracks,
- zoomLevel,
- startPos,
- currentPos,
- });
- },
- onSelectionChange: ({ intersectedIds, isAdditive }) => {
- if (isAdditive) {
- mergeElementsIntoSelection({ elements: intersectedIds });
- } else {
- setElementSelection({ elements: intersectedIds });
- }
- },
- });
-
- const containerWidth =
- tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
- const contentWidth = timelineTimeToPixels({
- time: timelineDuration,
- zoomLevel,
- });
- const paddingPx = getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom: minZoomLevel,
- });
- const dynamicTimelineWidth = Math.max(
- contentWidth + paddingPx,
- containerWidth,
- );
- const tracksViewportWidth =
- tracksScrollRef.current?.clientWidth ??
- tracksContainerRef.current?.clientWidth ??
- containerWidth;
- const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
-
- useEdgeAutoScroll({
- isActive: bookmarkDragState.isDragging,
- getMouseClientX: () => bookmarkLastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: dynamicTimelineWidth,
- });
-
- const showSnapIndicator =
- snappingEnabled &&
- currentSnapPoint !== null &&
- (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
-
- const {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
- } = useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration: timeline.getTotalDuration(),
- isSelecting,
- clearSelectedElements: clearElementSelection,
- seek,
- });
-
- const timelineHeaderHeight =
- (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
- TIMELINE_CONTENT_TOP_PADDING_PX || 0;
-
- return (
- <section
- className={
- "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
- }
- {...dragProps}
- aria-label="Timeline"
- >
- <TimelineToolbar
- zoomLevel={zoomLevel}
- minZoom={minZoomLevel}
- setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
- />
-
- <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
- <TrackLabelsPanel
- trackLabelsRef={trackLabelsRef}
- trackLabelsScrollRef={trackLabelsScrollRef}
- timelineHeaderHeight={timelineHeaderHeight}
- hasHorizontalScrollbar={hasHorizontalScrollbar}
- getTrackExpansionHeight={getTrackExpansionHeight}
- />
-
- <div
- className="relative isolate flex flex-1 flex-col overflow-hidden"
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <DragLine
- dropTarget={dropTarget}
- tracks={tracks}
- isVisible={isDragOver && !dropTarget?.targetElement}
- headerHeight={timelineHeaderHeight}
- />
- <DragLine
- dropTarget={dragDropTarget}
- tracks={tracks}
- isVisible={dragState.isDragging}
- headerHeight={timelineHeaderHeight}
- />
-
- <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
- <div
- ref={timelineHeaderRef}
- className="flex flex-col"
- style={{ width: `${dynamicTimelineWidth}px` }}
- >
- <TimelineRuler
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- rulerRef={rulerRef}
- tracksScrollRef={rulerScrollRef}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- <TimelineBookmarksRow
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- dragState={bookmarkDragState}
- onBookmarkMouseDown={handleBookmarkMouseDown}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- </div>
- </div>
-
- <ScrollArea
- className="flex-1"
- ref={tracksScrollRef}
- onScroll={() => {
- syncFollowers();
- saveScrollPosition();
- }}
- >
- <div
- className="flex min-h-full flex-col"
- style={{ width: `${dynamicTimelineWidth}px` }}
- >
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
- <div
- className="relative shrink-0"
- style={{
- height: `${
- Math.max(
- TRACKS_CONTAINER_HEIGHT.min,
- Math.min(
- TRACKS_CONTAINER_HEIGHT.max,
- getTotalTracksHeight({
- tracks,
- getExtraHeight: getTrackExpansionHeight,
- }),
- ),
- ) + TIMELINE_CONTENT_TOP_PADDING_PX
- }px`,
- }}
- onMouseDown={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksClick(event);
- }}
- >
- {tracks.length > 0 && (
- <TimelineTrackRows
- mainTrackId={mainTrackId}
- zoomLevel={zoomLevel}
- dragState={dragState}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onResizeStart={handleResizeStart}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- onTrackMouseDown={(event) => {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackMouseUp={handleTracksClick}
- shouldIgnoreClick={shouldIgnoreClick}
- isDragOver={isDragOver}
- dropTarget={dropTarget}
- />
- )}
- </div>
- <TimelineGutter
- onMouseDown={(event) => {
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={handleTracksClick}
- />
- </div>
- </ScrollArea>
-
- <TimelinePlayhead
- zoomLevel={zoomLevel}
- hasHorizontalScrollbar={hasHorizontalScrollbar}
- rulerRef={rulerRef}
- rulerScrollRef={rulerScrollRef}
- tracksScrollRef={tracksScrollRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
- </div>
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- timelineRef={timelineRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
- />
- </div>
- </section>
- );
-}
-
-function TrackLabelsPanel({
- trackLabelsRef,
- trackLabelsScrollRef,
- timelineHeaderHeight,
- hasHorizontalScrollbar,
- getTrackExpansionHeight,
-}: {
- trackLabelsRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
- timelineHeaderHeight: number;
- hasHorizontalScrollbar: boolean;
- getTrackExpansionHeight: (trackIndex: number) => number;
-}) {
- const editor = useEditor();
- const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const { selectedElements } = useElementSelection();
- const tracksWithSelection = useMemo(
- () => new Set(selectedElements.map((el) => el.trackId)),
- [selectedElements],
- );
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
- const trackExpandedRowsMap = useMemo(
- () =>
- tracks.map((track) =>
- getTrackExpandedRows({ track, expandedElementIds }),
- ),
- [tracks, expandedElementIds],
- );
-
- return (
- <div
- className="flex shrink-0 flex-col border-r"
- style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
- >
- <div
- className="shrink-0"
- style={{ height: timelineHeaderHeight || 48 }}
- />
- <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
- <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
- {tracks.length > 0 && (
- <div
- className="flex flex-col"
- style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
- >
- {tracks.map((track, index) => {
- const expandedRows = trackExpandedRowsMap[index];
- const baseHeight = getTrackHeight({ type: track.type });
-
- return (
- <div
- key={track.id}
- className={cn(
- "group flex flex-col",
- tracksWithSelection.has(track.id) &&
- SELECTED_TRACK_ROW_CLASS,
- )}
- style={{
- height: `${baseHeight + getTrackExpansionHeight(index)}px`,
- }}
- >
- <div
- className="flex shrink-0 items-center justify-end gap-2 px-3"
- style={{ height: `${baseHeight}px` }}
- >
- {canTrackHaveAudio(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>
- {expandedRows.length > 0 && (
- <PropertyTree rows={expandedRows} />
- )}
- </div>
- );
- })}
- </div>
- )}
- </div>
- </div>
- <div
- className="bg-background shrink-0"
- style={{
- height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
- }}
- />
- </div>
- );
-}
-
-function TimelineTrackRows({
- mainTrackId,
- zoomLevel,
- dragState,
- tracksScrollRef,
- lastMouseXRef,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- isDragOver,
- dropTarget,
-}: {
- mainTrackId: string | null;
- zoomLevel: number;
- dragState: ElementDragState;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onResizeStart: React.ComponentProps<
- typeof TimelineTrackContent
- >["onResizeStart"];
- onElementMouseDown: React.ComponentProps<
- typeof TimelineTrackContent
- >["onElementMouseDown"];
- onElementClick: React.ComponentProps<
- typeof TimelineTrackContent
- >["onElementClick"];
- onTrackMouseDown: (event: React.MouseEvent) => void;
- onTrackMouseUp: (event: React.MouseEvent) => void;
- shouldIgnoreClick: () => boolean;
- isDragOver: boolean;
- dropTarget: DropTarget | null;
-}) {
- const timeline = useEditor((e) => e.timeline);
- const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
- const tracks = useMemo<TimelineTrack[]>(
- () =>
- scene
- ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
- : [],
- [scene],
- );
- const { selectedElements } = useElementSelection();
- const tracksWithSelection = useMemo(
- () => new Set(selectedElements.map((el) => el.trackId)),
- [selectedElements],
- );
-
- const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
-
- const getTrackExpansionHeight = useCallback(
- (trackIndex: number) => {
- const track = tracks[trackIndex];
- if (!track) return 0;
- return computeTrackExpansionHeight({ track, expandedElementIds });
- },
- [tracks, expandedElementIds],
- );
-
- const sortedTracks = useMemo(() => {
- const draggingElementIds = new Set(dragState.dragElementIds);
- return [...tracks]
- .map((track, index) => ({ track, index }))
- .sort((a, b) => {
- const aHasDragged = a.track.elements.some((element) =>
- draggingElementIds.has(element.id),
- );
- const bHasDragged = b.track.elements.some((element) =>
- draggingElementIds.has(element.id),
- );
- if (aHasDragged) return 1;
- if (bHasDragged) return -1;
- return 0;
- });
- }, [tracks, dragState.dragElementIds]);
-
- return (
- <>
- {sortedTracks.map(({ track, index }) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className={cn(
- "absolute right-0 left-0 transition-colors",
- tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
- )}
- style={{
- top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
- height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- dragState={dragState}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onResizeStart={onResizeStart}
- onElementMouseDown={onElementMouseDown}
- onElementClick={onElementClick}
- onTrackMouseDown={onTrackMouseDown}
- onTrackMouseUp={onTrackMouseUp}
- shouldIgnoreClick={shouldIgnoreClick}
- targetElementId={
- isDragOver
- ? (dropTarget?.targetElement?.elementId ?? null)
- : null
- }
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-40">
- <ContextMenuItem
- icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.toggleTrackMute({ trackId: track.id });
- }}
- >
- {canTrackHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </ContextMenuItem>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={ViewIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.toggleTrackVisibility({ trackId: track.id });
- }}
- >
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </ContextMenuItem>
- {track.id !== mainTrackId && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Delete02Icon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- timeline.removeTrack({ trackId: track.id });
- }}
- variant="destructive"
- >
- Delete track
- </ContextMenuItem>
- )}
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </>
- );
-}
-
-function TimelineGutter({
- onMouseDown,
- onClick,
-}: {
- onMouseDown: (event: React.MouseEvent) => void;
- onClick: (event: React.MouseEvent) => void;
-}) {
- // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
- // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
- return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
-}
-
-function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_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}
- />
- )}
- </>
- );
-}
-
-function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
- return (
- <div className="flex flex-col overflow-hidden">
- {rows.map((row) => (
- <div
- key={row.propertyPath}
- className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
- style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
- >
- <span className="text-muted-foreground truncate text-xs leading-none">
- {getPropertyLabel(row.propertyPath)}
- </span>
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/interaction.ts b/apps/web/src/components/editor/panels/timeline/interaction.ts
deleted file mode 100644
index fe0210a4..00000000
--- a/apps/web/src/components/editor/panels/timeline/interaction.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const TIMELINE_DRAG_THRESHOLD_PX = 5;
-export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
-export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
-export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
diff --git a/apps/web/src/components/editor/panels/timeline/layers.ts b/apps/web/src/components/editor/panels/timeline/layers.ts
deleted file mode 100644
index d59db412..00000000
--- a/apps/web/src/components/editor/panels/timeline/layers.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const TIMELINE_LAYERS = {
- trackContent: 10,
- dragLine: 20,
- playhead: 30,
- snapIndicator: 40,
-} as const;
diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts
deleted file mode 100644
index c30b7877..00000000
--- a/apps/web/src/components/editor/panels/timeline/layout.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
- video: 65,
- text: 25,
- audio: 50,
- graphic: 25,
- effect: 25,
-} as const;
-
-export const KEYFRAME_LANE_HEIGHT_PX = 20;
-export const KEYFRAME_DIAMOND_SIZE_PX = 14;
-export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18;
-
-export const TIMELINE_TRACK_GAP_PX = 6;
-export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
-export const TIMELINE_RULER_HEIGHT_PX = 22;
-export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
-export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
-export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
deleted file mode 100644
index 551393e9..00000000
--- a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type { TimelineTrack } from "@/lib/timeline";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-import {
- TIMELINE_CONTENT_TOP_PADDING_PX,
-} from "./layout";
-import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
-
-type TimelineElementRef = { trackId: string; elementId: string };
-
-interface SelectionRectangle {
- left: number;
- top: number;
- right: number;
- bottom: number;
-}
-
-function getNormalizedRectangle({
- startPos,
- endPos,
-}: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- return {
- left: Math.min(startPos.x, endPos.x),
- top: Math.min(startPos.y, endPos.y),
- right: Math.max(startPos.x, endPos.x),
- bottom: Math.max(startPos.y, endPos.y),
- };
-}
-
-function getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- const containerRect = container.getBoundingClientRect();
- const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
-
- const adjustedStart = {
- x: startPos.x - containerRect.left + scrollLeft,
- y: startPos.y - scrollRect.top + scrollTop,
- };
- const adjustedEnd = {
- x: endPos.x - containerRect.left + scrollLeft,
- y: endPos.y - scrollRect.top + scrollTop,
- };
-
- return getNormalizedRectangle({
- startPos: adjustedStart,
- endPos: adjustedEnd,
- });
-}
-
-function isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
-}: {
- elementRectangle: SelectionRectangle;
- selectionRectangle: SelectionRectangle;
-}): boolean {
- return !(
- elementRectangle.right < selectionRectangle.left ||
- elementRectangle.left > selectionRectangle.right ||
- elementRectangle.bottom < selectionRectangle.top ||
- elementRectangle.top > selectionRectangle.bottom
- );
-}
-
-export function resolveTimelineElementIntersections({
- container,
- scrollContainer,
- tracks,
- zoomLevel,
- startPos,
- currentPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- tracks: TimelineTrack[];
- zoomLevel: number;
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
-}): TimelineElementRef[] {
- const selectionRectangle = getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos: currentPos,
- });
- const selectedElements: TimelineElementRef[] = [];
-
- for (const [trackIndex, track] of tracks.entries()) {
- const trackTop = getCumulativeHeightBefore({
- tracks,
- trackIndex,
- });
- const trackHeight = getTrackHeight({ type: track.type });
- const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
- const elementBottom = elementTop + trackHeight;
-
- for (const element of track.elements) {
- const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
- const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
- const elementRectangle = {
- left: elementLeft,
- top: elementTop,
- right: elementRight,
- bottom: elementBottom,
- };
-
- if (
- isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
- })
- ) {
- selectedElements.push({
- trackId: track.id,
- elementId: element.id,
- });
- }
- }
- }
-
- return selectedElements;
-}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
deleted file mode 100644
index a86e2a5e..00000000
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import {
- getCenteredLineLeft,
- TIMELINE_INDICATOR_LINE_WIDTH_PX,
-} from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-interface SnapIndicatorProps {
- snapPoint: SnapPoint | null;
- zoomLevel: number;
- isVisible: boolean;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-export function SnapIndicator({
- snapPoint,
- zoomLevel,
- isVisible,
- timelineRef,
- tracksScrollRef,
-}: SnapIndicatorProps) {
- const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- tracksScrollRef,
- });
-
- if (!isVisible || !snapPoint) {
- return null;
- }
-
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
- top: topPosition,
- height: `${height}px`,
- width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
- zIndex: TIMELINE_LAYERS.snapIndicator,
- }}
- >
- <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts
deleted file mode 100644
index 0afe3eb4..00000000
--- a/apps/web/src/components/editor/panels/timeline/theme.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)";
-
-export const TIMELINE_TRACK_THEME: Record<
- TrackType,
- {
- elementClassName: string;
- waveformColor?: string;
- }
-> = {
- video: { elementClassName: "transparent" },
- text: { elementClassName: "bg-[#5DBAA0]" },
- audio: {
- elementClassName: "bg-[#8F5DBA]",
- waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR,
- },
- graphic: { elementClassName: "bg-[#BA5D7A]" },
- effect: { elementClassName: "bg-[#5d93ba]" },
-} as const;
-
-export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
-export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
-
-export function getTimelineElementClassName({
- type,
-}: {
- type: TrackType;
-}): string {
- return TIMELINE_TRACK_THEME[type].elementClassName.trim();
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
deleted file mode 100644
index 75a2dfa8..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ /dev/null
@@ -1,1305 +0,0 @@
-"use client";
-
-import { createContext, useContext } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
-import { AudioVolumeLine } from "./audio-volume-line";
-import { useElementPreview } from "@/hooks/use-element-preview";
-import {
- useKeyframeDrag,
- type KeyframeDragState,
-} from "@/hooks/timeline/element/use-keyframe-drag";
-import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
-import { useKeyframeBoxSelect } from "@/hooks/timeline/element/use-keyframe-box-select";
-import { SelectionBox } from "@/lib/selection/selection-box";
-import { getElementKeyframes } from "@/lib/animation";
-import {
- canElementHaveAudio,
- canElementBeHidden,
- hasElementEffects,
- hasMediaId,
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { getTrackHeight } from "./track-layout";
-import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import type {
- TimelineElement as TimelineElementType,
- TimelineTrack,
- ElementDragState,
- VideoElement,
- ImageElement,
- AudioElement,
-} from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import {
- canToggleSourceAudio,
- getSourceAudioActionLabel,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
-import { getTimelinePixelsPerSecond } from "@/lib/timeline";
-import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
-import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
-import {
- getActionDefinition,
- type TAction,
- type TActionWithOptionalArgs,
- invokeAction,
-} from "@/lib/actions";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { resolveStickerId } from "@/lib/stickers";
-import { buildGraphicPreviewUrl } from "@/lib/graphics";
-import Image from "next/image";
-import {
- ScissorIcon,
- Delete02Icon,
- Copy01Icon,
- ViewIcon,
- ViewOffSlashIcon,
- VolumeHighIcon,
- VolumeOffIcon,
- VolumeMute02Icon,
- Search01Icon,
- Exchange01Icon,
- KeyframeIcon,
- MagicWand05Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { uppercase } from "@/utils/string";
-import { useMemo, type ComponentProps, type ReactNode } from "react";
-import type {
- SelectedKeyframeRef,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { cn } from "@/utils/ui";
-import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
-import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
-import {
- getExpandedRows,
- getExpansionHeight,
- type ExpandedRow,
-} from "./expanded-layout";
-
-const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
-const ELEMENT_RING_WIDTH_PX = 1.5;
-
-const PixelsPerSecondContext = createContext<number | null>(null);
-const THUMBNAIL_ASPECT_RATIO = 16 / 9;
-
-interface KeyframeIndicator {
- time: number;
- offsetPx: number;
- keyframes: SelectedKeyframeRef[];
-}
-
-export function buildKeyframeIndicator({
- keyframe,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
-}: {
- keyframe: ElementKeyframe;
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
-}): {
- time: number;
- offsetPx: number;
- keyframeRef: SelectedKeyframeRef;
-} {
- const keyframeRef = {
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- keyframeId: keyframe.id,
- };
- const keyframeLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + keyframe.time,
- zoomLevel,
- });
- return {
- time: keyframe.time,
- offsetPx: keyframeLeft - elementLeft,
- keyframeRef,
- };
-}
-
-export function getKeyframeIndicators({
- keyframes,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
-}: {
- keyframes: ElementKeyframe[];
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
- elementWidth: number;
-}): KeyframeIndicator[] {
- if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
- return [];
- }
-
- const keyframesByTime = new Map<number, KeyframeIndicator>();
- for (const keyframe of keyframes) {
- const indicator = buildKeyframeIndicator({
- keyframe,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- });
- const existingIndicator = keyframesByTime.get(indicator.time);
- if (!existingIndicator) {
- keyframesByTime.set(indicator.time, {
- time: indicator.time,
- offsetPx: indicator.offsetPx,
- keyframes: [indicator.keyframeRef],
- });
- continue;
- }
-
- existingIndicator.keyframes.push(indicator.keyframeRef);
- }
-
- return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
-}
-
-export function getDisplayShortcut({ action }: { 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;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- dragState: ElementDragState;
- isDropTarget?: boolean;
-}
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- dragState,
- isDropTarget = false,
-}: TimelineElementProps) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const { selectedElements } = useElementSelection();
- const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
- const { renderElement } = useElementPreview({
- trackId: track.id,
- elementId: element.id,
- fallback: element,
- });
-
- let mediaAsset: MediaAsset | null = null;
-
- if (hasMediaId(element)) {
- mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- }
-
- const hasAudio = mediaSupportsAudio({ media: mediaAsset });
-
- const isCurrentElementSelected = selectedElements.some(
- (selected) =>
- selected.elementId === element.id && selected.trackId === track.id,
- );
-
- const isBeingDragged = dragState.dragElementIds.includes(element.id);
- const dragOffsetY =
- isBeingDragged && dragState.isDragging
- ? dragState.currentMouseY - dragState.startMouseY
- : 0;
- const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime + dragTimeOffset
- : renderElement.startTime;
- const displayedStartTime = elementStartTime;
- const displayedDuration = renderElement.duration;
- const elementWidth = timelineTimeToPixels({
- time: displayedDuration,
- zoomLevel,
- });
- const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
- const elementLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime,
- zoomLevel,
- });
- const keyframeIndicators = isSelected
- ? getKeyframeIndicators({
- keyframes: getElementKeyframes({ animations: element.animations }),
- trackId: track.id,
- elementId: element.id,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- elementWidth,
- })
- : [];
-
- const {
- keyframeDragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
- } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
-
- const elementKeyframes = getElementKeyframes({
- animations: element.animations,
- });
-
- const isExpanded = useTimelineStore((s) =>
- s.expandedElementIds.has(element.id),
- );
- const toggleElementExpanded = useTimelineStore(
- (s) => s.toggleElementExpanded,
- );
- const expandedRows = useMemo(
- () =>
- isExpanded ? getExpandedRows({ animations: element.animations }) : [],
- [isExpanded, element.animations],
- );
-
- const {
- containerRef: expandedLanesRef,
- selectionBox: keyframeSelectionBox,
- isBoxSelecting: isKeyframeBoxSelecting,
- handleExpandedAreaMouseDown,
- handleExpandedAreaClick,
- } = useKeyframeBoxSelect({
- trackId: track.id,
- elementId: element.id,
- rows: expandedRows,
- keyframes: elementKeyframes,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- });
-
- const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
- event.stopPropagation();
- if (hasMediaId(element)) {
- requestRevealMedia(element.mediaId);
- }
- };
-
- const isMuted = canElementHaveAudio(element) && element.muted === true;
- const canToggleCurrentSourceAudio =
- selectedElements.length === 1 &&
- isCurrentElementSelected &&
- canToggleSourceAudio(element, mediaAsset);
- const sourceAudioLabel =
- element.type === "video"
- ? getSourceAudioActionLabel({ element })
- : "Extract audio";
- const isElementSourceAudioSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
- const hasKeyframes = elementKeyframes.length > 0;
- const expansionHeight = getExpansionHeight({ rows: expandedRows });
- const baseTrackHeight = getTrackHeight({ type: track.type });
-
- const expandedContent =
- isExpanded && expandedRows.length > 0 ? (
- <ExpandedKeyframeLanes
- rows={expandedRows}
- keyframes={elementKeyframes}
- trackId={track.id}
- elementId={element.id}
- displayedStartTime={displayedStartTime}
- zoomLevel={zoomLevel}
- elementLeft={elementLeft}
- keyframeDragState={keyframeDragState}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- containerRef={expandedLanesRef}
- onLaneMouseDown={handleExpandedAreaMouseDown}
- onLaneClick={handleExpandedAreaClick}
- selectionBox={keyframeSelectionBox}
- isBoxSelecting={isKeyframeBoxSelecting}
- />
- ) : null;
-
- return (
- <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}>
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className="absolute top-0 select-none"
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- height:
- expandedRows.length > 0
- ? `${baseTrackHeight + expansionHeight}px`
- : "100%",
- transform:
- isBeingDragged && dragState.isDragging
- ? `translate3d(0, ${dragOffsetY}px, 0)`
- : undefined,
- }}
- >
- <ElementInner
- element={element}
- displayElement={renderElement}
- track={track}
- isSelected={isSelected}
- isExpanded={expandedRows.length > 0}
- baseTrackHeight={baseTrackHeight}
- expandedContent={expandedContent}
- onElementClick={onElementClick}
- onElementMouseDown={onElementMouseDown}
- onResizeStart={onResizeStart}
- isDropTarget={isDropTarget}
- />
- {isSelected && (
- <div
- className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
- style={{ height: `${baseTrackHeight}px` }}
- >
- <KeyframeIndicators
- indicators={keyframeIndicators}
- dragState={keyframeDragState}
- displayedStartTime={displayedStartTime}
- elementLeft={elementLeft}
- onKeyframeMouseDown={handleKeyframeMouseDown}
- onKeyframeClick={handleKeyframeClick}
- getVisualOffsetPx={getVisualOffsetPx}
- />
- </div>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-64">
- <ActionMenuItem
- action="split"
- icon={<HugeiconsIcon icon={ScissorIcon} />}
- >
- Split
- </ActionMenuItem>
- <CopyMenuItem />
- {selectedElements.length === 1 && (
- <ActionMenuItem
- action="duplicate-selected"
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- >
- Duplicate
- </ActionMenuItem>
- )}
- {canElementHaveAudio(element) && hasAudio && (
- <MuteMenuItem
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- isMuted={isMuted}
- />
- )}
- {canToggleCurrentSourceAudio && (
- <ContextMenuItem
- icon={
- <HugeiconsIcon
- icon={
- isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
- }
- />
- }
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction("toggle-source-audio");
- }}
- >
- {sourceAudioLabel}
- </ContextMenuItem>
- )}
- {canElementBeHidden(element) && (
- <VisibilityMenuItem
- element={element}
- isMultipleSelected={selectedElements.length > 1}
- isCurrentElementSelected={isCurrentElementSelected}
- />
- )}
- {hasKeyframes && (
- <ContextMenuItem
- icon={<HugeiconsIcon icon={KeyframeIcon} />}
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- toggleElementExpanded(element.id);
- }}
- >
- {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
- </ContextMenuItem>
- )}
- {selectedElements.length === 1 && hasMediaId(element) && (
- <>
- <ContextMenuItem
- icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event: React.MouseEvent) =>
- 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>
- </PixelsPerSecondContext.Provider>
- );
-}
-
-function ElementInner({
- element,
- displayElement,
- track,
- isSelected,
- isExpanded,
- baseTrackHeight,
- expandedContent,
- onElementClick,
- onElementMouseDown,
- onResizeStart,
- isDropTarget = false,
-}: {
- element: TimelineElementType;
- displayElement?: TimelineElementType;
- track: TimelineTrack;
- isSelected: boolean;
- isExpanded: boolean;
- baseTrackHeight: number;
- expandedContent: React.ReactNode;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
- isDropTarget?: boolean;
-}) {
- const visibleElement = displayElement ?? element;
- const isReducedOpacity =
- (canElementBeHidden(visibleElement) && visibleElement.hidden) ||
- isDropTarget;
- return (
- <div
- className="absolute top-0 bottom-0"
- style={{
- left: `${ELEMENT_RING_WIDTH_PX}px`,
- right: `${ELEMENT_RING_WIDTH_PX}px`,
- }}
- >
- <div
- className="absolute inset-0 rounded-sm"
- style={
- isSelected
- ? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
- }
- : undefined
- }
- >
- <div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- isExpanded && "bg-background",
- )}
- >
- <button
- type="button"
- tabIndex={-1}
- className="absolute inset-0 size-full flex flex-col"
- onClick={(event) => onElementClick(event, element)}
- onMouseDown={(event) => onElementMouseDown(event, element)}
- >
- <div
- className={cn(
- "flex shrink-0 items-center overflow-hidden",
- getTimelineElementClassName({
- type: getTrackTypeForElementType({
- elementType: element.type,
- }),
- }),
- isReducedOpacity && "opacity-50",
- )}
- style={{ height: `${baseTrackHeight}px` }}
- >
- <div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
- <ElementContent element={visibleElement} track={track} />
- </div>
- </div>
- {expandedContent}
- </button>
- </div>
- </div>
-
- {isSelected && (
- <>
- <ResizeHandle
- side="left"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- <ResizeHandle
- side="right"
- element={element}
- track={track}
- onResizeStart={onResizeStart}
- />
- </>
- )}
- </div>
- );
-}
-
-function ResizeHandle({
- side,
- element,
- track,
- onResizeStart,
-}: {
- side: "left" | "right";
- element: TimelineElementType;
- track: TimelineTrack;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => void;
-}) {
- const isLeft = side === "left";
- return (
- <button
- type="button"
- className={cn(
- "absolute top-0 bottom-0 w-2",
- isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
- )}
- onMouseDown={(event) => onResizeStart({ event, element, track, side })}
- onClick={(event) => event.stopPropagation()}
- aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
- ></button>
- );
-}
-
-function KeyframeIndicators({
- indicators,
- dragState,
- displayedStartTime,
- elementLeft,
- onKeyframeMouseDown,
- onKeyframeClick,
- getVisualOffsetPx,
-}: {
- indicators: KeyframeIndicator[];
- dragState: KeyframeDragState;
- displayedStartTime: number;
- elementLeft: number;
- onKeyframeMouseDown: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => void;
- onKeyframeClick: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => void;
- getVisualOffsetPx: (params: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }) => number;
-}) {
- const { isKeyframeSelected } = useKeyframeSelection();
- const orderedKeyframes = indicators.flatMap(
- (indicator) => indicator.keyframes,
- );
-
- return indicators.map((indicator) => {
- const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
- const isBeingDragged = indicator.keyframes.some((keyframe) =>
- dragState.draggingKeyframeIds.has(keyframe.keyframeId),
- );
- const visualOffsetPx = getVisualOffsetPx({
- indicatorTime: indicator.time,
- indicatorOffsetPx: indicator.offsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- });
-
- return (
- <button
- key={indicator.time}
- type="button"
- className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
- style={{ left: visualOffsetPx }}
- onMouseDown={(event) =>
- onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
- }
- onClick={(event) =>
- onKeyframeClick({
- event,
- keyframes: indicator.keyframes,
- orderedKeyframes,
- indicatorTime: indicator.time,
- })
- }
- aria-label="Select keyframe"
- >
- <HugeiconsIcon
- icon={KeyframeIcon}
- className={cn(
- "size-3.5 text-black",
- isIndicatorSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- });
-}
-
-function ExpandedKeyframeLanes({
- rows,
- keyframes,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- keyframeDragState,
- onKeyframeMouseDown,
- onKeyframeClick,
- getVisualOffsetPx,
- containerRef,
- onLaneMouseDown,
- onLaneClick,
- selectionBox,
- isBoxSelecting,
-}: {
- rows: ExpandedRow[];
- keyframes: ElementKeyframe[];
- trackId: string;
- elementId: string;
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
- keyframeDragState: KeyframeDragState;
- onKeyframeMouseDown: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => void;
- containerRef: React.RefObject<HTMLDivElement | null>;
- onLaneMouseDown: (event: React.MouseEvent) => void;
- onLaneClick: (event: React.MouseEvent) => void;
- selectionBox: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
- } | null;
- isBoxSelecting: boolean;
- onKeyframeClick: (params: {
- event: React.MouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => void;
- getVisualOffsetPx: (params: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }) => number;
-}) {
- const { isKeyframeSelected } = useKeyframeSelection();
-
- const orderedKeyframes = useMemo(
- () =>
- [...keyframes]
- .sort(
- (a, b) =>
- a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
- )
- .map((kf) => ({
- trackId,
- elementId,
- propertyPath: kf.propertyPath,
- keyframeId: kf.id,
- })),
- [keyframes, trackId, elementId],
- );
-
- return (
- // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
- // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
- <div
- ref={containerRef}
- className="relative flex flex-col"
- onMouseDown={onLaneMouseDown}
- onClick={onLaneClick}
- >
- {rows.map((row) => {
- const laneKeyframes = keyframes.filter(
- (kf) => kf.propertyPath === row.propertyPath,
- );
- return (
- <div
- key={row.propertyPath}
- className={cn("relative flex items-center bg-muted/50")}
- style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
- >
- {laneKeyframes.map((kf) => {
- const keyframeRef: SelectedKeyframeRef = {
- trackId,
- elementId,
- propertyPath: row.propertyPath,
- keyframeId: kf.id,
- };
- const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
- kf.id,
- );
- const kfLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + kf.time,
- zoomLevel,
- });
- const offsetPx = kfLeft - elementLeft;
- const visualOffset = getVisualOffsetPx({
- indicatorTime: kf.time,
- indicatorOffsetPx: offsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- });
- const isSelected = isKeyframeSelected({
- keyframe: keyframeRef,
- });
-
- return (
- <button
- key={kf.id}
- type="button"
- className={cn(
- "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
- isBoxSelecting && "pointer-events-none",
- )}
- style={{ left: visualOffset }}
- onMouseDown={(event) => {
- event.stopPropagation();
- onKeyframeMouseDown({
- event,
- keyframes: [keyframeRef],
- });
- }}
- onClick={(event) => {
- event.stopPropagation();
- onKeyframeClick({
- event,
- keyframes: [keyframeRef],
- orderedKeyframes,
- indicatorTime: kf.time,
- });
- }}
- aria-label="Select keyframe"
- >
- <HugeiconsIcon
- icon={KeyframeIcon}
- className={cn(
- "size-3.5 text-black mr-1",
- isSelected ? "fill-primary" : "fill-white",
- )}
- strokeWidth={1.5}
- />
- </button>
- );
- })}
- </div>
- );
- })}
- {selectionBox && (
- <SelectionBox
- startPos={selectionBox.startPos}
- currentPos={selectionBox.currentPos}
- containerRef={containerRef}
- isActive={selectionBox.isActive}
- />
- )}
- </div>
- );
-}
-
-interface ElementContentProps {
- element: TimelineElementType;
- track: TimelineTrack;
-}
-
-function TextElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "text" }>;
-}) {
- return (
- <div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">{element.content}</span>
- </div>
- );
-}
-
-function EffectElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "effect" }>;
-}) {
- return (
- <div className="flex size-full items-center justify-start gap-1 pl-2">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-4 shrink-0 text-white"
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function StickerElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "sticker" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: element.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function GraphicElementContent({
- element,
-}: {
- element: Extract<TimelineElementType, { type: "graphic" }>;
-}) {
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={buildGraphicPreviewUrl({
- definitionId: element.definitionId,
- params: element.params,
- size: 20,
- })}
- alt={element.name}
- className="size-4 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">{element.name}</span>
- </div>
- );
-}
-
-function AudioElementContent({
- element,
- trackId,
-}: {
- element: AudioElement;
- trackId: string;
-}) {
- const pixelsPerSecond = useContext(PixelsPerSecondContext);
- if (pixelsPerSecond === null) {
- throw new Error(
- "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider",
- );
- }
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const mediaAsset =
- element.sourceType === "upload"
- ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
- : null;
-
- const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
- const audioUrl =
- element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
- const sourceFile =
- element.sourceType === "upload" ? mediaAsset?.file : undefined;
- const sourceKey =
- element.sourceType === "upload"
- ? buildWaveformSourceKey({ kind: "media", id: element.mediaId })
- : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl });
- const mediaLabel = mediaAsset?.name ?? element.name;
- const gainSamples = useMemo(
- () =>
- buildWaveformGainSamples({
- element,
- count: WAVEFORM_GAIN_SAMPLE_COUNT,
- }),
- [element],
- );
- if (audioBuffer || audioUrl || sourceFile) {
- return (
- <div className="group/audio relative size-full">
- <MediaElementHeader name={mediaLabel} hasFade={false} />
- <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden">
- <AudioWaveform
- sourceKey={sourceKey}
- sourceFile={sourceFile}
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- gainSamples={gainSamples}
- pixelsPerSecond={pixelsPerSecond}
- clipDurationSec={element.duration / TICKS_PER_SECOND}
- retime={element.retime}
- sourceStartSec={element.trimStart / TICKS_PER_SECOND}
- color={TIMELINE_TRACK_THEME.audio.waveformColor}
- />
- <AudioVolumeLine element={element} trackId={trackId} />
- </div>
- </div>
- );
- }
-
- return (
- <div className="group/audio relative size-full">
- <div className="flex size-full items-center pl-2">
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
- </div>
- <AudioVolumeLine element={element} trackId={trackId} />
- </div>
- );
-}
-
-function EffectsButton({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const editor = useEditor();
- const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
-
- const handleClick = (event: React.MouseEvent) => {
- event.stopPropagation();
- editor.selection.setSelectedElements({
- elements: [{ trackId: track.id, elementId: element.id }],
- });
- setActiveTab(element.type, "effects");
- };
-
- return (
- <button
- type="button"
- className="flex shrink-0 justify-center text-white cursor-pointer"
- onMouseDown={(event) => event.stopPropagation()}
- onClick={handleClick}
- >
- <HugeiconsIcon icon={MagicWand05Icon} size={12} />
- </button>
- );
-}
-
-function TiledMediaContent({
- element,
- track,
-}: {
- element: VideoElement | ImageElement;
- track: TimelineTrack;
-}) {
- const mediaAssets = useEditor((e) => e.media.getAssets());
-
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- const imageUrl =
- element.type === "video"
- ? mediaAsset?.thumbnailUrl
- : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
-
- if (!imageUrl) {
- return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
- );
- }
-
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-
- return (
- <>
- <div
- className="absolute inset-0"
- style={{
- backgroundColor: "var(--muted)",
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
- <MediaElementHeader
- name={mediaAsset?.name}
- leading={
- hasElementEffects({ element }) ? (
- <EffectsButton element={element} track={track} />
- ) : null
- }
- hasFade={true}
- />
- </>
- );
-}
-
-function MediaElementHeader({
- name,
- leading,
- hasFade,
-}: {
- name?: string | null;
- leading?: ReactNode;
- hasFade?: boolean;
-}) {
- if (!name && !leading) {
- return null;
- }
-
- return (
- <div
- className={cn(
- "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1",
- hasFade && "from-black/30 to-transparent",
- )}
- >
- {leading && <div className="pl-1">{leading}</div>}
- {name && (
- <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
- {name}
- </span>
- )}
- </div>
- );
-}
-
-function ElementContent({ element, track }: ElementContentProps) {
- switch (element.type) {
- case "text":
- return <TextElementContent element={element} />;
- case "effect":
- return <EffectElementContent element={element} />;
- case "sticker":
- return <StickerElementContent element={element} />;
- case "graphic":
- return <GraphicElementContent element={element} />;
- case "audio":
- return <AudioElementContent element={element} trackId={track.id} />;
- case "video":
- case "image":
- return <TiledMediaContent element={element} track={track} />;
- }
-}
-
-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={VolumeOffIcon} />
- ) : (
- <HugeiconsIcon icon={VolumeHighIcon} />
- );
- };
-
- 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: TActionWithOptionalArgs;
- children: ReactNode;
-}) {
- return (
- <ContextMenuItem
- onClick={(event: React.MouseEvent) => {
- event.stopPropagation();
- invokeAction(action);
- }}
- textRight={getDisplayShortcut({ action })}
- {...props}
- >
- {children}
- </ContextMenuItem>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
deleted file mode 100644
index d9218371..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-"use client";
-
-import { useRef } from "react";
-import {
- getCenteredLineLeft,
- TIMELINE_INDICATOR_LINE_WIDTH_PX,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useEditor } from "@/hooks/use-editor";
-import {
- TIMELINE_SCROLLBAR_SIZE_PX,
-} from "./layout";
-import { TIMELINE_LAYERS } from "./layers";
-
-interface TimelinePlayheadProps {
- zoomLevel: number;
- hasHorizontalScrollbar: boolean;
- 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,
- hasHorizontalScrollbar,
- 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 { handlePlayheadMouseDown } = useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- const timelineContainerHeight =
- timelineRef.current?.clientHeight ??
- tracksScrollRef.current?.clientHeight ??
- 400;
- const totalHeight = Math.max(
- 0,
- timelineContainerHeight -
- (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
- );
-
- const currentTime = editor.playback.getCurrentTime();
- const centerPosition = timelineTimeToSnappedPixels({
- time: currentTime,
- zoomLevel,
- });
- const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
- const leftPosition =
- getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
-
- const handlePlayheadKeyDown = (
- event: React.KeyboardEvent<HTMLDivElement>,
- ) => {
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
-
- event.preventDefault();
- const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
- const direction = event.key === "ArrowRight" ? 1 : -1;
- const now = editor.playback.getCurrentTime();
- const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame));
-
- editor.playback.seek({ time: nextTime });
- };
-
- return (
- <div
- ref={playheadRef}
- role="slider"
- aria-label="Timeline playhead"
- aria-valuemin={0}
- aria-valuemax={duration}
- aria-valuenow={currentTime}
- tabIndex={0}
- className="pointer-events-none absolute"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
- zIndex: TIMELINE_LAYERS.playhead,
- }}
- onKeyDown={handlePlayheadKeyDown}
- >
- <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
-
- <button
- type="button"
- aria-label="Drag playhead"
- className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
- onMouseDown={handlePlayheadMouseDown}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
deleted file mode 100644
index 7b69e820..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { type JSX, useLayoutEffect, useRef } from "react";
-import {
- BASE_TIMELINE_PIXELS_PER_SECOND,
-} from "@/lib/timeline/scale";
-import { mediaTimeToSeconds } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
-import { DEFAULT_FPS } from "@/lib/fps/defaults";
-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 durationTicks = useEditor((e) => e.timeline.getTotalDuration());
- const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
- const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
- const fps =
- useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
- const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
- zoomLevel,
- fps,
- });
- const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
-
- const { scrollLeft, viewportWidth } = useScrollPosition({
- scrollRef: tracksScrollRef,
- });
-
- /**
- * widens the virtualization buffer during zoom transitions.
- * useScrollPosition lags one frame behind the scroll adjustment
- * that useLayoutEffect applies after a zoom change.
- */
- const prevZoomRef = useRef(zoomLevel);
- const isZoomTransition = zoomLevel !== prevZoomRef.current;
- const bufferPx = isZoomTransition
- ? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
- : 200;
-
- useLayoutEffect(() => {
- prevZoomRef.current = zoomLevel;
- }, [zoomLevel]);
-
- const visibleStartTimeSeconds = Math.max(
- 0,
- (scrollLeft - bufferPx) / pixelsPerSecond,
- );
- const visibleEndTimeSeconds =
- (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
-
- const startTickIndex = Math.max(
- 0,
- Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
- );
- const endTickIndex = Math.min(
- tickCount - 1,
- Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
- );
-
- const timelineTicks: Array<JSX.Element> = [];
- for (
- let tickIndex = startTickIndex;
- tickIndex <= endTickIndex;
- tickIndex += 1
- ) {
- const timeSeconds = tickIndex * tickIntervalSeconds;
- if (timeSeconds > effectiveDurationSeconds) break;
-
- const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
- const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds });
- timelineTicks.push(
- <TimelineTick
- key={tickIndex}
- time={timeTicks}
- timeInSeconds={timeSeconds}
- zoomLevel={zoomLevel}
- fps={fps}
- showLabel={showLabel}
- />,
- );
- }
-
- return (
- <div
- role="slider"
- tabIndex={0}
- aria-label="Timeline ruler"
- aria-valuemin={0}
- aria-valuemax={effectiveDurationSeconds}
- aria-valuenow={0}
- className="relative flex-1 overflow-x-visible"
- style={{ height: TIMELINE_RULER_HEIGHT_PX }}
- onWheel={handleWheel}
- onClick={(event) => {
- // Ruler seek already happens on mousedown via playhead scrubbing.
- // Forwarding the follow-up click re-enters the selection-clearing path.
- if (event.target === event.currentTarget) {
- handleTimelineContentClick(event);
- }
- }}
- onMouseDown={handleRulerTrackingMouseDown}
- onKeyDown={() => {}}
- >
- <div
- role="none"
- ref={rulerRef}
- className="relative cursor-default select-none"
- style={{
- height: TIMELINE_RULER_HEIGHT_PX,
- width: `${dynamicTimelineWidth}px`,
- }}
- onMouseDown={handleRulerMouseDown}
- >
- {timelineTicks}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
deleted file mode 100644
index fca55752..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import type { FrameRate } from "opencut-wasm";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
-
-interface TimelineTickProps {
- time: number;
- timeInSeconds: number;
- zoomLevel: number;
- fps: FrameRate;
- showLabel: boolean;
-}
-
-export function TimelineTick({
- time,
- timeInSeconds,
- zoomLevel,
- fps,
- showLabel,
-}: TimelineTickProps) {
- const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
-
- if (showLabel) {
- const label = formatRulerLabel({ timeInSeconds, fps });
- return (
- <span
- className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
- style={{ left: `${leftPosition}px` }}
- >
- {label}
- </span>
- );
- }
-
- return (
- <div
- className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
- style={{ left: `${leftPosition}px` }}
- />
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
deleted file mode 100644
index 80ea10d0..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip";
-import { Button } from "@/components/ui/button";
-import {
- SplitButton,
- SplitButtonLeft,
- SplitButtonRight,
- SplitButtonSeparator,
-} from "@/components/ui/split-button";
-import { Slider } from "@/components/ui/slider";
-import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
-import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
-import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
-import { ScenesView } from "@/components/editor/scenes-view";
-import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
-import {
- canToggleSourceAudio,
- getSourceAudioActionLabel,
- isSourceAudioSeparated,
-} from "@/lib/timeline/audio-separation";
-import { hasMediaId } from "@/lib/timeline";
-import { cn } from "@/utils/ui";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- Bookmark02Icon,
- Delete02Icon,
- SnowIcon,
- ScissorIcon,
- MagnetIcon,
- SearchAddIcon,
- SearchMinusIcon,
- Copy01Icon,
- AlignLeftIcon,
- AlignRightIcon,
- Link02Icon,
- Layers01Icon,
- Chart03Icon,
- Unlink02Icon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { OcRippleIcon } from "@/components/icons";
-import { GraphEditorPopover } from "./graph-editor/popover";
-import { PopoverTrigger } from "@/components/ui/popover";
-import { useGraphEditorController } from "./graph-editor/use-controller";
-
-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_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
- : Math.max(minZoom, zoomLevel / TIMELINE_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 mediaAssets = useEditor((currentEditor) =>
- currentEditor.media.getAssets(),
- );
- const { selectedElements } = useElementSelection();
- const graphEditor = useGraphEditorController();
- const isCurrentlyBookmarked = useEditor((e) =>
- e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
- );
- const selectedElement =
- selectedElements.length === 1
- ? (editor.timeline.getElementsWithTracks({
- elements: selectedElements,
- })[0] ?? null)
- : null;
- const selectedMediaAsset = (() => {
- if (!selectedElement) {
- return null;
- }
-
- const { element } = selectedElement;
- if (!hasMediaId(element)) {
- return null;
- }
-
- return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
- })();
- const canToggleSelectedSourceAudio =
- !!selectedElement &&
- canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
- const sourceAudioLabel =
- selectedElement?.element.type === "video"
- ? getSourceAudioActionLabel({
- element: selectedElement.element,
- })
- : "Extract audio";
- const isSelectedSourceAudioSeparated =
- selectedElement?.element.type === "video" &&
- isSourceAudioSeparated({
- element: selectedElement.element,
- });
-
- const handleAction = ({
- action,
- event,
- }: {
- action: TActionWithOptionalArgs;
- event: React.MouseEvent;
- }) => {
- event.stopPropagation();
- invokeAction(action);
- };
-
- return (
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <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={
- <HugeiconsIcon
- icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
- />
- }
- tooltip={sourceAudioLabel}
- disabled={!canToggleSelectedSourceAudio}
- onClick={({ event }) =>
- handleAction({ action: "toggle-source-audio", event })
- }
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={Copy01Icon} />}
- tooltip="Duplicate element"
- onClick={({ event }) =>
- handleAction({ action: "duplicate-selected", event })
- }
- />
-
- <ToolbarButton
- icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Freeze frame (coming soon)"
- 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} />}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
- onClick={({ event }) =>
- handleAction({ action: "toggle-bookmark", event })
- }
- />
- </Tooltip>
-
- <GraphEditorPopover
- open={graphEditor.open}
- onOpenChange={graphEditor.onOpenChange}
- value={
- graphEditor.state.status === "ready"
- ? graphEditor.state.cubicBezier
- : null
- }
- message={graphEditor.state.message}
- componentOptions={graphEditor.state.componentOptions}
- activeComponentKey={graphEditor.state.activeComponentKey}
- onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
- onPreviewValue={graphEditor.onPreviewValue}
- onCommitValue={graphEditor.onCommitValue}
- onCancelPreview={graphEditor.onCancelPreview}
- >
- <ToolbarButton
- icon={<HugeiconsIcon icon={Chart03Icon} />}
- tooltip={graphEditor.tooltip}
- disabled={!graphEditor.canOpen}
- buttonWrapper={(button) =>
- graphEditor.canOpen ? (
- <PopoverTrigger asChild>{button}</PopoverTrigger>
- ) : (
- button
- )
- }
- />
- </GraphEditorPopover>
- </TooltipProvider>
- </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={() => {}}>
- <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 = useTimelineStore((s) => s.snappingEnabled);
- const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
- const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
- const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
-
- return (
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <ToolbarButton
- icon={<HugeiconsIcon icon={MagnetIcon} />}
- isActive={snappingEnabled}
- tooltip="Auto snapping"
- onClick={() => toggleSnapping()}
- />
-
- <ToolbarButton
- icon={<OcRippleIcon size={24} className="scale-110" />}
- isActive={rippleEditingEnabled}
- 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"
- 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"
- onClick={() => onZoom({ direction: "in" })}
- >
- <HugeiconsIcon icon={SearchAddIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function ToolbarButton({
- icon,
- tooltip,
- onClick,
- disabled,
- isActive,
- buttonWrapper,
-}: {
- icon: React.ReactNode;
- tooltip: string;
- onClick?: ({ event }: { event: React.MouseEvent }) => void;
- disabled?: boolean;
- isActive?: boolean;
- buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
-}) {
- const button = (
- <Button
- variant={isActive ? "secondary" : "text"}
- size="icon"
- disabled={disabled}
- onClick={onClick ? (event) => onClick({ event }) : undefined}
- className={cn(
- "rounded-sm",
- disabled ? "cursor-not-allowed opacity-50" : "",
- )}
- >
- {icon}
- </Button>
- );
- const trigger = disabled ? (
- <span className="inline-flex">{button}</span>
- ) : buttonWrapper ? (
- buttonWrapper(button)
- ) : (
- button
- );
-
- return (
- <Tooltip delayDuration={200}>
- <TooltipTrigger asChild>{trigger}</TooltipTrigger>
- <TooltipContent>{tooltip}</TooltipContent>
- </Tooltip>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
deleted file mode 100644
index 32b1b93d..00000000
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client";
-
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/lib/timeline";
-import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "./layers";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/lib/timeline";
-import { useEditor } from "@/hooks/use-editor";
-
-interface TimelineTrackContentProps {
- track: TimelineTrack;
- zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
- onResizeStart: (params: {
- event: React.MouseEvent;
- element: TimelineElementType;
- track: TimelineTrack;
- side: "left" | "right";
- }) => 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;
- onTrackMouseUp?: (event: React.MouseEvent) => void;
- shouldIgnoreClick?: () => boolean;
- targetElementId?: string | null;
-}
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
- onResizeStart,
- onElementMouseDown,
- onElementClick,
- onTrackMouseDown,
- onTrackMouseUp,
- shouldIgnoreClick,
- targetElementId = null,
-}: TimelineTrackContentProps) {
- const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
-
- return (
- <div className="relative size-full">
- <button
- type="button"
- className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
- aria-label={`Select ${track.name} track`}
- onMouseUp={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- />
- {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
- <div
- className="relative h-full min-w-full"
- style={{ zIndex: TIMELINE_LAYERS.trackContent }}
- onMouseUp={(event) => {
- if (event.target !== event.currentTarget) return;
- if (shouldIgnoreClick?.()) return;
- onTrackMouseUp?.(event);
- }}
- onMouseDown={(event) => {
- if (event.target !== event.currentTarget) return;
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- >
- {track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
- ) : (
- track.elements.map((element) => {
- const isSelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onResizeStart={({ event, element, side }) =>
- onResizeStart({ event, element, track, side })
- }
- onElementMouseDown={(event, element) =>
- onElementMouseDown({ event, element, track })
- }
- onElementClick={(event, element) =>
- onElementClick({ event, element, track })
- }
- dragState={dragState}
- isDropTarget={element.id === targetElementId}
- />
- );
- })
- )}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts
deleted file mode 100644
index 8f833a57..00000000
--- a/apps/web/src/components/editor/panels/timeline/track-layout.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-import {
- KEYFRAME_LANE_HEIGHT_PX,
- TIMELINE_TRACK_GAP_PX,
- TIMELINE_TRACK_HEIGHTS_PX,
-} from "./layout";
-
-export function getTrackHeight({ type }: { type: TrackType }): number {
- return TIMELINE_TRACK_HEIGHTS_PX[type];
-}
-
-export function getExpandedTrackHeight({
- type,
- expandedLaneCount,
-}: {
- type: TrackType;
- expandedLaneCount: number;
-}): number {
- return (
- TIMELINE_TRACK_HEIGHTS_PX[type] +
- expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX
- );
-}
-
-export function getCumulativeHeightBefore({
- tracks,
- trackIndex,
- getExtraHeight,
-}: {
- tracks: Array<{ type: TrackType }>;
- trackIndex: number;
- getExtraHeight?: (trackIndex: number) => number;
-}): number {
- return tracks
- .slice(0, trackIndex)
- .reduce(
- (sum, track, i) =>
- sum +
- getTrackHeight({ type: track.type }) +
- (getExtraHeight?.(i) ?? 0) +
- TIMELINE_TRACK_GAP_PX,
- 0,
- );
-}
-
-export function getTotalTracksHeight({
- tracks,
- getExtraHeight,
-}: {
- tracks: Array<{ type: TrackType }>;
- getExtraHeight?: (trackIndex: number) => number;
-}): number {
- const tracksHeight = tracks.reduce(
- (sum, track, i) =>
- sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
- 0,
- );
- const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
- return tracksHeight + gapsHeight;
-}