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