mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: restructure files to their domains, new preview overlay system, and dep graph
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user