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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user