refactor: restructure files to their domains, new preview overlay system, and dep graph

This commit is contained in:
Maze Winther
2026-04-20 11:31:17 +02:00
parent 729d10592f
commit 3e89d29985
491 changed files with 3565 additions and 2372 deletions
@@ -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>
</>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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 }}
/>
);
}
@@ -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;
}
@@ -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;
}
@@ -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>
);
}
@@ -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();
}
@@ -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] },
];
@@ -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>
);
}
@@ -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,
);
}
@@ -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,
};
}
+946
View File
@@ -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>
);
}
@@ -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;
@@ -0,0 +1,6 @@
export const TIMELINE_LAYERS = {
trackContent: 10,
dragLine: 20,
playhead: 30,
snapIndicator: 40,
} as const;
@@ -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;
@@ -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;
}
@@ -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>
);
}
+31
View File
@@ -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();
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
@@ -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>
);
}
@@ -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` }}
/>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
}