diff options
Diffstat (limited to 'apps/web/src/timeline/components')
26 files changed, 6117 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx new file mode 100644 index 00000000..1dbb3d1a --- /dev/null +++ b/apps/web/src/timeline/components/audio-tab.tsx @@ -0,0 +1,151 @@ +import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx new file mode 100644 index 00000000..1baac2bd --- /dev/null +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + getDbFromLinePos, + getLinePosFromDb, +} from "@/timeline/audio-display"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { hasAnimatedVolume } from "@/timeline/audio-state"; +import type { AudioElement } from "@/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/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx new file mode 100644 index 00000000..7777bc09 --- /dev/null +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -0,0 +1,345 @@ +"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 "@/media/waveform-summary"; +import type { RetimeConfig } from "@/timeline"; +import { getBarFractionFromOutputAmplitude } from "@/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/timeline/components/drag-line.tsx b/apps/web/src/timeline/components/drag-line.tsx new file mode 100644 index 00000000..49621f5c --- /dev/null +++ b/apps/web/src/timeline/components/drag-line.tsx @@ -0,0 +1,29 @@ +import { getDropLineY } from "./drop-target";
+import type { TimelineTrack, DropTarget } from "@/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/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts new file mode 100644 index 00000000..be2495e4 --- /dev/null +++ b/apps/web/src/timeline/components/drop-target.ts @@ -0,0 +1,262 @@ +import type { TimelineTrack, TimelineElement } from "@/timeline"; +import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; +import { resolveTrackPlacement } from "@/timeline/placement"; +import { TIMELINE_TRACK_GAP_PX } from "./layout"; +import { getTrackHeight } from "./track-layout"; +import { TICKS_PER_SECOND } from "@/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/timeline/components/expanded-layout.ts b/apps/web/src/timeline/components/expanded-layout.ts new file mode 100644 index 00000000..fae0e50c --- /dev/null +++ b/apps/web/src/timeline/components/expanded-layout.ts @@ -0,0 +1,117 @@ +import type { + AnimationPath, + ElementAnimations, +} from "@/animation/types"; +import type { TimelineTrack } from "@/timeline"; +import { getElementKeyframes } from "@/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/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..7d77b985 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useRef, useState, type PointerEvent } from "react"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/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/timeline/components/graph-editor/custom-presets-store.ts b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts new file mode 100644 index 00000000..4be2adfd --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { generateUUID } from "@/utils/id"; +import type { NormalizedCubicBezier } from "@/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/timeline/components/graph-editor/easing-presets.ts b/apps/web/src/timeline/components/graph-editor/easing-presets.ts new file mode 100644 index 00000000..78a605d4 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/easing-presets.ts @@ -0,0 +1,19 @@ +import type { NormalizedCubicBezier } from "@/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/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx new file mode 100644 index 00000000..1b8194c0 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"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 "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/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/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts new file mode 100644 index 00000000..d7c6b001 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/session.ts @@ -0,0 +1,704 @@ +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getEasingModeForKind, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/animation/types"; +import type { SceneTracks, TimelineElement } from "@/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/timeline/components/graph-editor/use-controller.ts b/apps/web/src/timeline/components/graph-editor/use-controller.ts new file mode 100644 index 00000000..ea091485 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/use-controller.ts @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { useKeyframeSelection } from "@/timeline/hooks/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/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx new file mode 100644 index 00000000..e6a9df49 --- /dev/null +++ b/apps/web/src/timeline/components/index.tsx @@ -0,0 +1,946 @@ +"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 "@/timeline/hooks/use-timeline-zoom"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/timeline"; +import { TimelineTrackContent } from "./timeline-track"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SelectionBox } from "@/selection/selection-box"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineTrack } from "@/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 "@/timeline/hooks/element/use-element-interaction"; +import { + canTrackHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, +} from "@/timeline"; +import { timelineTimeToPixels } from "@/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 "@/timeline/hooks/element/use-element-selection"; +import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek"; +import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { + TimelineBookmarksRow, + useBookmarkDrag, +} from "@/timeline/bookmarks/index"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom"; +import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { useEditor } from "@/editor/use-editor"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/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/timeline/components/interaction.ts b/apps/web/src/timeline/components/interaction.ts new file mode 100644 index 00000000..fe0210a4 --- /dev/null +++ b/apps/web/src/timeline/components/interaction.ts @@ -0,0 +1,4 @@ +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/timeline/components/layers.ts b/apps/web/src/timeline/components/layers.ts new file mode 100644 index 00000000..d59db412 --- /dev/null +++ b/apps/web/src/timeline/components/layers.ts @@ -0,0 +1,6 @@ +export const TIMELINE_LAYERS = { + trackContent: 10, + dragLine: 20, + playhead: 30, + snapIndicator: 40, +} as const; diff --git a/apps/web/src/timeline/components/layout.ts b/apps/web/src/timeline/components/layout.ts new file mode 100644 index 00000000..57a7f5df --- /dev/null +++ b/apps/web/src/timeline/components/layout.ts @@ -0,0 +1,20 @@ +import type { TrackType } from "@/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/timeline/components/selection-hit-testing.ts b/apps/web/src/timeline/components/selection-hit-testing.ts new file mode 100644 index 00000000..da5617c6 --- /dev/null +++ b/apps/web/src/timeline/components/selection-hit-testing.ts @@ -0,0 +1,135 @@ +import type { TimelineTrack } from "@/timeline"; +import { timelineTimeToPixels } from "@/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/timeline/components/snap-indicator.tsx b/apps/web/src/timeline/components/snap-indicator.tsx new file mode 100644 index 00000000..6c1dbf1a --- /dev/null +++ b/apps/web/src/timeline/components/snap-indicator.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useSnapIndicatorPosition } from "@/timeline/hooks/use-snap-indicator-position"; +import type { SnapPoint } from "@/timeline/snapping"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, +} from "@/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/timeline/components/theme.ts b/apps/web/src/timeline/components/theme.ts new file mode 100644 index 00000000..9f93dc16 --- /dev/null +++ b/apps/web/src/timeline/components/theme.ts @@ -0,0 +1,31 @@ +import type { TrackType } from "@/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/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx new file mode 100644 index 00000000..74c34607 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -0,0 +1,1302 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; +import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform"; +import { AudioVolumeLine } from "./audio-volume-line"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/timeline/hooks/element/use-keyframe-drag"; +import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; +import { useKeyframeBoxSelect } from "@/timeline/hooks/element/use-keyframe-box-select"; +import { SelectionBox } from "@/selection/selection-box"; +import { getElementKeyframes } from "@/animation"; +import { + canElementHaveAudio, + canElementBeHidden, + hasElementEffects, + hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/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 "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { mediaSupportsAudio } from "@/media/media-utils"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { buildWaveformGainSamples } from "@/timeline/audio-state"; +import { getTimelinePixelsPerSecond } from "@/timeline"; +import { buildWaveformSourceKey } from "@/media/waveform-summary"; +import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/actions"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { resolveStickerId } from "@/stickers"; +import { buildGraphicPreviewUrl } from "@/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 "@/animation/types"; +import { cn } from "@/utils/ui"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; +import { getTrackTypeForElementType } from "@/timeline/placement/compatibility"; +import { useTimelineStore } from "@/timeline/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/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx new file mode 100644 index 00000000..4dd3329f --- /dev/null +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useRef } from "react"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/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/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx new file mode 100644 index 00000000..e2a379ee --- /dev/null +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -0,0 +1,146 @@ +import { type JSX, useLayoutEffect, useRef } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { mediaTimeToSeconds } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_RULER_HEIGHT_PX } from "./layout"; +import { DEFAULT_FPS } from "@/fps/defaults"; +import { useEditor } from "@/editor/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/timeline/ruler-utils"; +import { useScrollPosition } from "@/timeline/hooks/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/timeline/components/timeline-tick.tsx b/apps/web/src/timeline/components/timeline-tick.tsx new file mode 100644 index 00000000..1b1e46d3 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-tick.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { FrameRate } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { formatRulerLabel } from "@/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/timeline/components/timeline-toolbar.tsx b/apps/web/src/timeline/components/timeline-toolbar.tsx new file mode 100644 index 00000000..22acd447 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-toolbar.tsx @@ -0,0 +1,376 @@ +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/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 "@/timeline/scale"; +import { sliderToZoom, zoomToSlider } from "@/timeline/zoom-utils"; +import { ScenesView } from "@/components/editor/scenes-view"; +import { type TActionWithOptionalArgs, invokeAction } from "@/actions"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { hasMediaId } from "@/timeline"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/timeline/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/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx new file mode 100644 index 00000000..6c40637e --- /dev/null +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { TimelineElement } from "./timeline-element"; +import type { TimelineTrack } from "@/timeline"; +import type { TimelineElement as TimelineElementType } from "@/timeline"; +import { TIMELINE_LAYERS } from "./layers"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import type { ElementDragState } from "@/timeline"; +import { useEditor } from "@/editor/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/timeline/components/track-layout.ts b/apps/web/src/timeline/components/track-layout.ts new file mode 100644 index 00000000..59f4a5f9 --- /dev/null +++ b/apps/web/src/timeline/components/track-layout.ts @@ -0,0 +1,60 @@ +import type { TrackType } from "@/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; +} |
