From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../editor/panels/timeline/audio-volume-line.tsx | 275 ----- .../editor/panels/timeline/audio-waveform.tsx | 345 ------ .../editor/panels/timeline/bookmarks.tsx | 427 ------- .../editor/panels/timeline/drag-line.tsx | 29 - .../editor/panels/timeline/drop-target.ts | 262 ---- .../editor/panels/timeline/expanded-layout.ts | 117 -- .../panels/timeline/graph-editor/bezier-graph.tsx | 235 ---- .../timeline/graph-editor/custom-presets-store.ts | 101 -- .../panels/timeline/graph-editor/easing-presets.ts | 19 - .../panels/timeline/graph-editor/popover.tsx | 334 ----- .../editor/panels/timeline/graph-editor/session.ts | 704 ----------- .../panels/timeline/graph-editor/use-controller.ts | 178 --- .../components/editor/panels/timeline/index.tsx | 944 -------------- .../editor/panels/timeline/interaction.ts | 4 - .../components/editor/panels/timeline/layers.ts | 6 - .../components/editor/panels/timeline/layout.ts | 20 - .../panels/timeline/selection-hit-testing.ts | 135 -- .../editor/panels/timeline/snap-indicator.tsx | 50 - .../src/components/editor/panels/timeline/theme.ts | 31 - .../editor/panels/timeline/timeline-element.tsx | 1305 -------------------- .../editor/panels/timeline/timeline-playhead.tsx | 114 -- .../editor/panels/timeline/timeline-ruler.tsx | 141 --- .../editor/panels/timeline/timeline-tick.tsx | 42 - .../editor/panels/timeline/timeline-toolbar.tsx | 376 ------ .../editor/panels/timeline/timeline-track.tsx | 132 -- .../editor/panels/timeline/track-layout.ts | 60 - 26 files changed, 6386 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/audio-waveform.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/bookmarks.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/drag-line.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/drop-target.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/expanded-layout.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/session.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/index.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/interaction.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/layers.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/layout.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/snap-indicator.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/theme.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-element.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-tick.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-track.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/track-layout.ts (limited to 'apps/web/src/components/editor/panels/timeline') 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(null); - const activePointerIdRef = useRef(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) => { - 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 ( -
-
-
- {/* 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 */} -
- {isDragging && - tooltipClientPos && - createPortal( -
- {volumeLabel} -
, - document.body, - )} -
-
- ); -} 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(null); - const containerRef = useRef(null); - const summaryRef = useRef(null); - const gainSamplesRef = useRef(gainSamples); - const pixelsPerSecondRef = useRef(pixelsPerSecond); - const clipDurationSecRef = useRef(clipDurationSec); - const retimeRef = useRef(retime); - const sourceStartSecRef = useRef(sourceStartSec); - const scrollParentRef = useRef(null); - const heightRef = useRef(0); - const lastRenderSignatureRef = useRef(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 ( -
- -
- ); -} 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 ( -
- -
- ); -} - -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 ( - - - - - event.preventDefault()} - > - setIsPopoverOpen(false)} - /> - - - ); -} - -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 ( - <> -
- - handleUpdate({ note: event.target.value })} - className="h-8 text-sm" - /> -
-
- -
- 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() && ( - - )} -
-
-
- -
- { - 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" - /> -
-
- - - ); -} 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 ( -
- ); -} 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> = { - "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; -}): 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; -}): 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(null); - const [activeHandle, setActiveHandle] = useState(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) => { - event.preventDefault(); - event.stopPropagation(); - setActiveHandle(handle); - event.currentTarget.setPointerCapture(event.pointerId); - }; - } - - function onPointerMove({ event }: { event: PointerEvent }) { - 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 ( - onPointerMove({ event })} - onPointerUp={onPointerUp} - onPointerCancel={onPointerCancel} - > - Bezier curve editor - - - - - - - - - - ); -} 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 ( - { - if (!nextOpen) { - onCancelPreview?.(); - } - onOpenChange?.(nextOpen); - }} - > - {children} - - {componentOptions.length > 1 && ( -
-
- {componentOptions.map((component) => ( - - ))} -
-
- )} - -
- {value ? ( - - ) : ( - - )} -
- - - - - Presets - - - Saved - - - - COLLAPSED_MAX} - onExpand={() => setIsExpanded(true)} - > - {BUILTIN_PRESETS.map((preset) => ( - onCommitValue?.(preset.value)} - /> - ))} - - - -
- {custom.map((preset) => ( - onCommitValue?.(preset.value)} - onDelete={() => removePreset({ id: preset.id })} - /> - ))} - -
-
-
-
-
- ); -} - -function GraphEditorEmptyState({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - -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 ( -
-
- {children} -
- {!isExpanded && shouldExpand && ( -
- -
- )} -
- ); -} - -function PresetItem({ - preset, - isActive, - onSelect, - onDelete, - disabled, -}: { - preset: EasingPreset; - isActive: boolean; - onSelect: () => void; - onDelete?: () => void; - disabled?: boolean; -}) { - return ( - - )} - - ); -} - -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 ( - - Curve preset preview - - - ); -} 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[] = []; - - 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( - (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( - null, - ); - const hasPreviewRef = useRef(false); - - const state = useMemo( - () => - 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 = { - video: , - text: ( - - ), - audio: ( - - ), - graphic: , - effect: ( - - ), -}; - -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( - () => - 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(null); - const timelineHeaderRef = useRef(null); - const rulerRef = useRef(null); - const rulerScrollRef = useRef(null); - const tracksContainerRef = useRef(null); - const tracksScrollRef = useRef(null); - const trackLabelsRef = useRef(null); - const playheadRef = useRef(null); - const trackLabelsScrollRef = useRef(null); - - const [currentSnapPoint, setCurrentSnapPoint] = useState( - 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 | 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 ( -
- setZoomLevel(zoom)} - /> - -
- - -
- - - - -
-
- - -
-
- - { - syncFollowers(); - saveScrollPosition(); - }} - > -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} -
{ - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={(event) => { - const isDirectTarget = event.target === event.currentTarget; - if (!isDirectTarget) return; - event.stopPropagation(); - handleTracksClick(event); - }} - > - {tracks.length > 0 && ( - { - handleSelectionMouseDown(event); - handleTracksMouseDown(event); - }} - onTrackMouseUp={handleTracksClick} - shouldIgnoreClick={shouldIgnoreClick} - isDragOver={isDragOver} - dropTarget={dropTarget} - /> - )} -
- { - handleTracksMouseDown(event); - handleSelectionMouseDown(event); - }} - onClick={handleTracksClick} - /> -
-
- - -
- -
-
- ); -} - -function TrackLabelsPanel({ - trackLabelsRef, - trackLabelsScrollRef, - timelineHeaderHeight, - hasHorizontalScrollbar, - getTrackExpansionHeight, -}: { - trackLabelsRef: React.RefObject; - trackLabelsScrollRef: React.RefObject; - timelineHeaderHeight: number; - hasHorizontalScrollbar: boolean; - getTrackExpansionHeight: (trackIndex: number) => number; -}) { - const editor = useEditor(); - const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); - const tracks = useMemo( - () => - 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 ( -
-
-
-
- {tracks.length > 0 && ( -
- {tracks.map((track, index) => { - const expandedRows = trackExpandedRowsMap[index]; - const baseHeight = getTrackHeight({ type: track.type }); - - return ( -
-
- {canTrackHaveAudio(track) && ( - - editor.timeline.toggleTrackMute({ - trackId: track.id, - }) - } - /> - )} - {canTrackBeHidden(track) && ( - - editor.timeline.toggleTrackVisibility({ - trackId: track.id, - }) - } - /> - )} - -
- {expandedRows.length > 0 && ( - - )} -
- ); - })} -
- )} -
-
-
-
- ); -} - -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; - lastMouseXRef: React.RefObject; - 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( - () => - 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 }) => ( - - -
- -
-
- - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("paste-copied"); - }} - > - Paste elements - - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.toggleTrackMute({ trackId: track.id }); - }} - > - {canTrackHaveAudio(track) && track.muted - ? "Unmute track" - : "Mute track"} - - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.toggleTrackVisibility({ trackId: track.id }); - }} - > - {canTrackBeHidden(track) && track.hidden - ? "Show track" - : "Hide track"} - - {track.id !== mainTrackId && ( - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - timeline.removeTrack({ trackId: track.id }); - }} - variant="destructive" - > - Delete track - - )} - -
- ))} - - ); -} - -function TimelineGutter({ - onMouseDown, - onClick, -}: { - onMouseDown: (event: React.MouseEvent) => void; - onClick: (event: React.MouseEvent) => void; -}) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return
; -} - -function TrackIcon({ track }: { track: TimelineTrack }) { - return <>{TRACK_ICONS[track.type]}; -} - -function TrackToggleIcon({ - isOff, - icons, - onClick, -}: { - isOff: boolean; - icons: { - on: IconSvgElement; - off: IconSvgElement; - }; - onClick: () => void; -}) { - return ( - <> - {isOff ? ( - - ) : ( - - )} - - ); -} - -function PropertyTree({ rows }: { rows: ExpandedRow[] }) { - return ( -
- {rows.map((row) => ( -
- - {getPropertyLabel(row.propertyPath)} - -
- ))} -
- ); -} 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 = { - 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; - tracksScrollRef: React.RefObject; -} - -export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - timelineRef, - tracksScrollRef, -}: SnapIndicatorProps) { - const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ - snapPoint, - zoomLevel, - timelineRef, - tracksScrollRef, - }); - - if (!isVisible || !snapPoint) { - return null; - } - - return ( -
-
-
- ); -} 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(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(); - 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 ? ( - - ) : null; - - return ( - - - -
0 - ? `${baseTrackHeight + expansionHeight}px` - : "100%", - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - 0} - baseTrackHeight={baseTrackHeight} - expandedContent={expandedContent} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - onResizeStart={onResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( -
- -
- )} -
-
- - } - > - Split - - - {selectedElements.length === 1 && ( - } - > - Duplicate - - )} - {canElementHaveAudio(element) && hasAudio && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canToggleCurrentSourceAudio && ( - - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction("toggle-source-audio"); - }} - > - {sourceAudioLabel} - - )} - {canElementBeHidden(element) && ( - 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {hasKeyframes && ( - } - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - toggleElementExpanded(element.id); - }} - > - {isExpanded ? "Collapse keyframes" : "Expand keyframes"} - - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - } - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) - } - > - Reveal media - - } - disabled - > - Replace media - - - )} - - 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> - -
-
- ); -} - -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 ( -
-
-
- -
-
- - {isSelected && ( - <> - - - - )} -
- ); -} - -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 ( - - ); -} - -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 ( - - ); - }); -} - -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; - 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 -
- {rows.map((row) => { - const laneKeyframes = keyframes.filter( - (kf) => kf.propertyPath === row.propertyPath, - ); - return ( -
- {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 ( - - ); - })} -
- ); - })} - {selectionBox && ( - - )} -
- ); -} - -interface ElementContentProps { - element: TimelineElementType; - track: TimelineTrack; -} - -function TextElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.content} -
- ); -} - -function EffectElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- - {element.name} -
- ); -} - -function StickerElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.name} - {element.name} -
- ); -} - -function GraphicElementContent({ - element, -}: { - element: Extract; -}) { - return ( -
- {element.name} - {element.name} -
- ); -} - -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 ( -
- -
- - -
-
- ); - } - - return ( -
-
- - {element.name} - -
- -
- ); -} - -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 ( - - ); -} - -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 ( - - {element.name} - - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; - - return ( - <> -
- - ) : null - } - hasFade={true} - /> - - ); -} - -function MediaElementHeader({ - name, - leading, - hasFade, -}: { - name?: string | null; - leading?: ReactNode; - hasFade?: boolean; -}) { - if (!name && !leading) { - return null; - } - - return ( -
- {leading &&
{leading}
} - {name && ( - - {name} - - )} -
- ); -} - -function ElementContent({ element, track }: ElementContentProps) { - switch (element.type) { - case "text": - return ; - case "effect": - return ; - case "sticker": - return ; - case "graphic": - return ; - case "audio": - return ; - case "video": - case "image": - return ; - } -} - -function CopyMenuItem() { - return ( - } - > - Copy - - ); -} - -function MuteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - isMuted, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - isMuted: boolean; -}) { - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return ; - } - return isMuted ? ( - - ) : ( - - ); - }; - - return ( - - {isMuted ? "Unmute" : "Mute"} - - ); -} - -function VisibilityMenuItem({ - element, - isMultipleSelected, - isCurrentElementSelected, -}: { - element: TimelineElementType; - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; -}) { - const isHidden = canElementBeHidden(element) && element.hidden; - - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return ; - } - return isHidden ? ( - - ) : ( - - ); - }; - - return ( - - {isHidden ? "Show" : "Hide"} - - ); -} - -function DeleteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - elementType, - selectedCount, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - elementType: TimelineElementType["type"]; - selectedCount: number; -}) { - return ( - } - > - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedCount} elements` - : `Delete ${elementType === "text" ? "text" : "clip"}`} - - ); -} - -function ActionMenuItem({ - action, - children, - ...props -}: Omit, "onClick" | "textRight"> & { - action: TActionWithOptionalArgs; - children: ReactNode; -}) { - return ( - { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut({ action })} - {...props} - > - {children} - - ); -} 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; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - timelineRef: React.RefObject; - playheadRef?: React.RefObject; - 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(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, - ) => { - 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 ( -
-
- -
- ); -} 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; - tracksScrollRef: React.RefObject; - 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 = []; - 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( - , - ); - } - - return ( -
{ - // 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={() => {}} - > -
- {timelineTicks} -
-
- ); -} 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 ( - - {label} - - ); - } - - return ( -
- ); -} 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 ( - -
- - - - - setZoomLevel({ zoom })} - onZoom={handleZoom} - /> -
-
- ); -} - -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 ( -
- - } - tooltip="Split element" - onClick={({ event }) => handleAction({ action: "split", event })} - /> - - } - tooltip="Split left" - onClick={({ event }) => handleAction({ action: "split-left", event })} - /> - - } - tooltip="Split right" - onClick={({ event }) => - handleAction({ action: "split-right", event }) - } - /> - - - } - tooltip={sourceAudioLabel} - disabled={!canToggleSelectedSourceAudio} - onClick={({ event }) => - handleAction({ action: "toggle-source-audio", event }) - } - /> - - } - tooltip="Duplicate element" - onClick={({ event }) => - handleAction({ action: "duplicate-selected", event }) - } - /> - - } - tooltip="Freeze frame (coming soon)" - disabled={true} - onClick={({ event: _event }) => {}} - /> - - } - tooltip="Delete element" - onClick={({ event }) => - handleAction({ action: "delete-selected", event }) - } - /> - -
- - - } - isActive={isCurrentlyBookmarked} - tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} - onClick={({ event }) => - handleAction({ action: "toggle-bookmark", event }) - } - /> - - - - } - tooltip={graphEditor.tooltip} - disabled={!graphEditor.canOpen} - buttonWrapper={(button) => - graphEditor.canOpen ? ( - {button} - ) : ( - button - ) - } - /> - - -
- ); -} - -function SceneSelector() { - const editor = useEditor(); - const currentScene = editor.scenes.getActiveScene(); - - return ( -
- - {currentScene?.name || "No Scene"} - - - {}}> - - - - -
- ); -} - -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 ( -
- - } - isActive={snappingEnabled} - tooltip="Auto snapping" - onClick={() => toggleSnapping()} - /> - - } - isActive={rippleEditingEnabled} - tooltip="Ripple editing" - onClick={() => toggleRippleEditing()} - /> - - -
- -
- - - onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) - } - min={0} - max={1} - step={0.005} - /> - -
-
- ); -} - -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 = ( - - ); - const trigger = disabled ? ( - {button} - ) : buttonWrapper ? ( - buttonWrapper(button) - ) : ( - button - ); - - return ( - - {trigger} - {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; - tracksScrollRef: React.RefObject; - lastMouseXRef: React.RefObject; - 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 ( -
-