summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/graph-editor
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/graph-editor
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/graph-editor')
-rw-r--r--apps/web/src/timeline/components/graph-editor/bezier-graph.tsx235
-rw-r--r--apps/web/src/timeline/components/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/timeline/components/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/timeline/components/graph-editor/popover.tsx334
-rw-r--r--apps/web/src/timeline/components/graph-editor/session.ts704
-rw-r--r--apps/web/src/timeline/components/graph-editor/use-controller.ts178
6 files changed, 1571 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
new file mode 100644
index 00000000..7d77b985
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useRef, useState, type PointerEvent } from "react";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { getBezierPoint } from "@/animation/bezier";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import { cn } from "@/utils/ui";
+
+const GRAPH_WIDTH = 140;
+const GRAPH_HEIGHT = 94;
+const GRAPH_PADDING = 12;
+const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
+const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
+const HANDLE_RADIUS = 3.5;
+const ENDPOINT_RADIUS = 2;
+const SNAP_THRESHOLD = 0.06;
+const SNAP_TARGETS = [0, 1];
+const CURVE_SEGMENTS = 64;
+const Y_CLAMP_MIN = -0.5;
+const Y_CLAMP_MAX = 1.5;
+
+type BezierHandle = "c1" | "c2";
+
+export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
+
+function snap({
+ value,
+ targets,
+ isEnabled,
+}: {
+ value: number;
+ targets: number[];
+ isEnabled: boolean;
+}) {
+ if (!isEnabled) return value;
+ for (const target of targets) {
+ if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
+ }
+ return value;
+}
+
+function toSvgX({ value }: { value: number }) {
+ return GRAPH_PADDING + value * GRAPH_WIDTH;
+}
+
+function toSvgY({ value }: { value: number }) {
+ return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
+}
+
+function fromSvgX({ svgX }: { svgX: number }) {
+ return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
+}
+
+function fromSvgY({ svgY }: { svgY: number }) {
+ return Math.max(
+ Y_CLAMP_MIN,
+ Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
+ );
+}
+
+function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= CURVE_SEGMENTS; i++) {
+ const progress = i / CURVE_SEGMENTS;
+ const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
+ const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
+ points.push(`${x},${y}`);
+ }
+ return `M${points.join("L")}`;
+}
+
+function clampHandleY({ svgY }: { svgY: number }) {
+ return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
+}
+
+export function BezierGraph({
+ value,
+ onChange,
+ onChangeEnd,
+ onCancel,
+}: {
+ value: NormalizedCubicBezier;
+ onChange?: (value: NormalizedCubicBezier) => void;
+ onChangeEnd?: (value: NormalizedCubicBezier) => void;
+ onCancel?: () => void;
+}) {
+ const svgRef = useRef<SVGSVGElement>(null);
+ const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
+ const isShiftPressedRef = useShiftKey();
+ const latestValueRef = useRef(value);
+
+ latestValueRef.current = value;
+
+ function getPointerPosition({
+ event,
+ }: {
+ event: PointerEvent;
+ }): { x: number; y: number } {
+ const svg = svgRef.current;
+ if (!svg) return { x: 0, y: 0 };
+ const rect = svg.getBoundingClientRect();
+ const scale = SVG_WIDTH / rect.width;
+ return {
+ x: (event.clientX - rect.left) * scale,
+ y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
+ };
+ }
+
+ function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
+ return (event: PointerEvent<SVGCircleElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setActiveHandle(handle);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ }
+
+ function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
+ if (!activeHandle) return;
+ const pointerPos = getPointerPosition({ event });
+ const x = fromSvgX({ svgX: pointerPos.x });
+ const y = snap({
+ value: fromSvgY({ svgY: pointerPos.y }),
+ targets: SNAP_TARGETS,
+ isEnabled: !isShiftPressedRef.current,
+ });
+ const next: NormalizedCubicBezier = [...value];
+ if (activeHandle === "c1") {
+ next[0] = x;
+ next[1] = y;
+ } else {
+ next[2] = x;
+ next[3] = y;
+ }
+ latestValueRef.current = next;
+ onChange?.(next);
+ }
+
+ function onPointerUp() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onChangeEnd?.(latestValueRef.current);
+ }
+
+ function onPointerCancel() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onCancel?.();
+ }
+
+ const path = curvePath({ curve: value });
+ const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
+ const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
+ const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
+ const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
+ const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
+ const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
+
+ return (
+ <svg
+ ref={svgRef}
+ viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
+ className="bg-foreground/3 w-full cursor-crosshair select-none"
+ onPointerMove={(event) => onPointerMove({ event })}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerCancel}
+ >
+ <title>Bezier curve editor</title>
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={p1.x}
+ y2={p1.y}
+ className="stroke-foreground/8"
+ strokeWidth={1}
+ strokeDasharray="3 3"
+ />
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={c1Clamped.x}
+ y2={c1Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <line
+ x1={p1.x}
+ y1={p1.y}
+ x2={c2Clamped.x}
+ y2={c2Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <path
+ d={path}
+ fill="none"
+ className="stroke-primary"
+ strokeWidth={2}
+ strokeLinecap="round"
+ />
+ <circle
+ cx={p0.x}
+ cy={p0.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={p1.x}
+ cy={p1.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={c1Clamped.x}
+ cy={c1Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c1" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c1" })}
+ />
+ <circle
+ cx={c2Clamped.x}
+ cy={c2Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c2" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c2" })}
+ />
+ </svg>
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts
new file mode 100644
index 00000000..4be2adfd
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { generateUUID } from "@/utils/id";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import type { EasingPreset } from "./easing-presets";
+
+const STORAGE_KEY = "graph-editor-presets";
+
+let cachedPresets: EasingPreset[] | null = null;
+const listeners = new Set<() => void>();
+
+function isValidPresetArray(value: unknown): value is EasingPreset[] {
+ return (
+ Array.isArray(value) &&
+ value.every(
+ (item) =>
+ typeof item === "object" &&
+ item !== null &&
+ typeof item.id === "string" &&
+ typeof item.label === "string" &&
+ Array.isArray(item.value) &&
+ item.value.length === 4 &&
+ item.value.every((number: unknown) => typeof number === "number"),
+ )
+ );
+}
+
+function readFromStorage(): EasingPreset[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ return isValidPresetArray(parsed) ? parsed : [];
+ } catch {
+ // Silently recover — corrupted localStorage shouldn't crash the editor
+ return [];
+ }
+}
+
+function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+}
+
+function getSnapshot(): EasingPreset[] {
+ cachedPresets ??= readFromStorage();
+ return cachedPresets;
+}
+
+function getServerSnapshot(): EasingPreset[] {
+ return [];
+}
+
+function notify(): void {
+ cachedPresets = null;
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function onStorageChange(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) notify();
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.addEventListener("storage", onStorageChange);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.removeEventListener("storage", onStorageChange);
+ }
+ };
+}
+
+export function useCustomPresets(): EasingPreset[] {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
+ const current = getSnapshot();
+ writeToStorage({
+ presets: [
+ ...current,
+ {
+ id: generateUUID(),
+ label: `Custom ${current.length + 1}`,
+ value,
+ isCustom: true,
+ },
+ ],
+ });
+ notify();
+}
+
+export function removePreset({ id }: { id: string }): void {
+ writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
+ notify();
+}
diff --git a/apps/web/src/timeline/components/graph-editor/easing-presets.ts b/apps/web/src/timeline/components/graph-editor/easing-presets.ts
new file mode 100644
index 00000000..78a605d4
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/easing-presets.ts
@@ -0,0 +1,19 @@
+import type { NormalizedCubicBezier } from "@/animation/types";
+
+export const PRESET_MATCH_TOLERANCE = 0.02;
+
+export interface EasingPreset {
+ id: string;
+ label: string;
+ value: NormalizedCubicBezier;
+ isCustom?: boolean;
+}
+
+export const BUILTIN_PRESETS: EasingPreset[] = [
+ { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
+ { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
+ { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
+ { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
+ { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
+ { id: "linear", label: "Linear", value: [0, 0, 1, 1] },
+];
diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx
new file mode 100644
index 00000000..1b8194c0
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/popover.tsx
@@ -0,0 +1,334 @@
+"use client";
+
+import { useState } from "react";
+import { Popover, PopoverContent } from "@/components/ui/popover";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowDown01Icon,
+ Delete02Icon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { getBezierPoint } from "@/animation/bezier";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import type { GraphEditorComponentOption } from "./session";
+import {
+ BUILTIN_PRESETS,
+ PRESET_MATCH_TOLERANCE,
+ type EasingPreset,
+} from "./easing-presets";
+import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
+import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
+
+const COLLAPSED_MAX = 6;
+const THUMB_SEGMENTS = 24;
+const THUMB_WIDTH = 40;
+const THUMB_HEIGHT = 22;
+const THUMB_PADDING_X = 4;
+const THUMB_PADDING_Y = 3;
+const COLLAPSED_GRID_MAX_HEIGHT = 120;
+const EXPANDED_GRID_MAX_HEIGHT = 240;
+
+export function GraphEditorPopover({
+ children,
+ side,
+ open,
+ onOpenChange,
+ value,
+ message,
+ componentOptions,
+ activeComponentKey,
+ onActiveComponentKeyChange,
+ onPreviewValue,
+ onCommitValue,
+ onCancelPreview,
+}: {
+ children: React.ReactNode;
+ side?: "top" | "bottom" | "left" | "right";
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ value: NormalizedCubicBezier | null;
+ message: string;
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ onActiveComponentKeyChange?: (componentKey: string) => void;
+ onPreviewValue?: (value: NormalizedCubicBezier) => void;
+ onCommitValue?: (value: NormalizedCubicBezier) => void;
+ onCancelPreview?: () => void;
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const custom = useCustomPresets();
+ const allPresets = [...BUILTIN_PRESETS, ...custom];
+ const canEdit = value !== null;
+ const activePresetId =
+ value == null
+ ? null
+ : (allPresets.find((preset) =>
+ preset.value.every(
+ (presetValue, index) =>
+ Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
+ ),
+ )?.id ?? null);
+
+ return (
+ <Popover
+ open={open}
+ onOpenChange={(nextOpen) => {
+ if (!nextOpen) {
+ onCancelPreview?.();
+ }
+ onOpenChange?.(nextOpen);
+ }}
+ >
+ {children}
+ <PopoverContent
+ side={side}
+ sideOffset={8}
+ className="w-60 overflow-hidden px-0"
+ >
+ {componentOptions.length > 1 && (
+ <div className="border-b px-3 py-2">
+ <div className="bg-muted/40 inline-flex rounded-md p-0.5">
+ {componentOptions.map((component) => (
+ <button
+ key={component.key}
+ type="button"
+ onClick={() => onActiveComponentKeyChange?.(component.key)}
+ className={cn(
+ "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
+ activeComponentKey === component.key
+ ? "bg-background text-foreground shadow-xs"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {component.label}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="px-3 py-3">
+ {value ? (
+ <BezierGraph
+ value={value}
+ onChange={onPreviewValue}
+ onChangeEnd={onCommitValue}
+ onCancel={onCancelPreview}
+ />
+ ) : (
+ <GraphEditorEmptyState message={message} />
+ )}
+ </div>
+
+ <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
+ <TabsList className="px-3">
+ <TabsTrigger value="presets" className="text-xs">
+ Presets
+ </TabsTrigger>
+ <TabsTrigger value="saved" className="text-xs">
+ Saved
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent value="presets" className="px-3 pb-0">
+ <ExpandableGrid
+ isExpanded={isExpanded}
+ shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
+ onExpand={() => setIsExpanded(true)}
+ >
+ {BUILTIN_PRESETS.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ />
+ ))}
+ </ExpandableGrid>
+ </TabsContent>
+ <TabsContent value="saved" className="px-3">
+ <div className="grid grid-cols-3 gap-1">
+ {custom.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ onDelete={() => removePreset({ id: preset.id })}
+ />
+ ))}
+ <button
+ type="button"
+ onClick={() => value && savePreset({ value })}
+ disabled={!canEdit}
+ className={cn(
+ "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
+ canEdit
+ ? "hover:bg-foreground/5 cursor-pointer"
+ : "cursor-not-allowed opacity-50",
+ )}
+ >
+ <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
+ <HugeiconsIcon
+ icon={PlusSignIcon}
+ className="size-3.5 opacity-40"
+ />
+ </div>
+ <span className="text-[10px] leading-tight">Save</span>
+ </button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GraphEditorEmptyState({ message }: { message: string }) {
+ return (
+ <div
+ style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
+ className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
+ >
+ {message}
+ </div>
+ );
+}
+
+function ExpandableGrid({
+ children,
+ isExpanded,
+ shouldExpand,
+ onExpand,
+}: {
+ children: React.ReactNode;
+ isExpanded: boolean;
+ shouldExpand: boolean;
+ onExpand: () => void;
+}) {
+ const gridStyle = shouldExpand
+ ? isExpanded
+ ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
+ : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
+ : undefined;
+
+ return (
+ <div className="relative">
+ <div className="grid grid-cols-3 gap-1" style={gridStyle}>
+ {children}
+ </div>
+ {!isExpanded && shouldExpand && (
+ <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5"
+ onClick={onExpand}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown01Icon}
+ className="text-muted-foreground size-3"
+ />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function PresetItem({
+ preset,
+ isActive,
+ onSelect,
+ onDelete,
+ disabled,
+}: {
+ preset: EasingPreset;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onSelect}
+ disabled={disabled}
+ className={cn(
+ "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
+ disabled
+ ? "cursor-not-allowed opacity-50"
+ : "hover:bg-foreground/5 cursor-pointer",
+ isActive && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
+ isActive && "bg-primary/5!",
+ )}
+ >
+ <CurveThumb value={preset.value} />
+ </div>
+ <span
+ className={cn(
+ "text-[10px] leading-tight",
+ isActive ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {preset.label}
+ </span>
+ {onDelete && (
+ <Button
+ variant="destructive"
+ size="icon"
+ className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
+ onClick={(event) => {
+ event.stopPropagation();
+ onDelete();
+ }}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ )}
+ </button>
+ );
+}
+
+function toThumbX({ value }: { value: number }) {
+ return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2);
+}
+
+function toThumbY({ value }: { value: number }) {
+ return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2);
+}
+
+function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= THUMB_SEGMENTS; i++) {
+ const progress = i / THUMB_SEGMENTS;
+ const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) });
+ const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) });
+ points.push(`${x},${y}`);
+ }
+ return (
+ <svg
+ width={THUMB_WIDTH}
+ height={THUMB_HEIGHT}
+ viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
+ >
+ <title>Curve preset preview</title>
+ <path
+ d={`M${points.join("L")}`}
+ fill="none"
+ className="stroke-current"
+ strokeWidth={1.5}
+ strokeLinecap="round"
+ />
+ </svg>
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts
new file mode 100644
index 00000000..d7c6b001
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/session.ts
@@ -0,0 +1,704 @@
+import {
+ getCurveHandlesForNormalizedCubicBezier,
+ getEditableScalarChannels,
+ getEasingModeForKind,
+ getNormalizedCubicBezierForScalarSegment,
+ getScalarKeyframeContext,
+ updateScalarKeyframeCurve,
+} from "@/animation";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ NormalizedCubicBezier,
+ ScalarCurveKeyframePatch,
+ ScalarGraphKeyframeContext,
+ SelectedKeyframeRef,
+} from "@/animation/types";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+
+const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
+const FLAT_VALUE_EPSILON = 1e-6;
+const LINEAR_CURVE_EPSILON = 1e-6;
+
+export type GraphEditorUnavailableReason =
+ | "no-keyframe-selected"
+ | "multiple-keyframes-selected"
+ | "selected-keyframes-span-multiple-elements"
+ | "selected-keyframes-are-not-adjacent"
+ | "selected-properties-have-no-shared-component"
+ | "selected-element-missing"
+ | "selected-element-has-no-animations"
+ | "selected-keyframe-has-no-scalar-channel"
+ | "selected-keyframe-missing-on-channel"
+ | "selected-keyframe-has-no-next-segment"
+ | "selected-segment-is-hold"
+ | "selected-segment-is-flat";
+
+export interface GraphEditorComponentOption {
+ key: string;
+ label: string;
+}
+
+interface GraphEditorPropertyOption {
+ key: string;
+ label: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+}
+
+export interface GraphEditorResolvedSegment {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}
+
+interface GraphEditorBaseSelectionState {
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ message: string;
+}
+
+export interface GraphEditorUnavailableState
+ extends GraphEditorBaseSelectionState {
+ status: "unavailable";
+ reason: GraphEditorUnavailableReason;
+}
+
+export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
+ status: "ready";
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ segments: GraphEditorResolvedSegment[];
+ cubicBezier: NormalizedCubicBezier;
+}
+
+export type GraphEditorSelectionState =
+ | GraphEditorUnavailableState
+ | GraphEditorReadyState;
+
+export interface GraphEditorCurvePatch {
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}
+
+function createUnavailableState({
+ reason,
+ message,
+ componentOptions = [],
+ activeComponentKey = null,
+}: {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ componentOptions?: GraphEditorComponentOption[];
+ activeComponentKey?: string | null;
+}): GraphEditorUnavailableState {
+ return {
+ status: "unavailable",
+ reason,
+ message,
+ componentOptions,
+ activeComponentKey,
+ };
+}
+
+function findElementByKeyframe({
+ tracks,
+ keyframe,
+}: {
+ tracks: SceneTracks;
+ keyframe: SelectedKeyframeRef;
+}): { element: TimelineElement; trackId: string; elementId: string } | null {
+ for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
+ if (track.id !== keyframe.trackId) {
+ continue;
+ }
+
+ const element = track.elements.find(
+ (trackElement) => trackElement.id === keyframe.elementId,
+ );
+ if (!element) {
+ return null;
+ }
+
+ return {
+ element,
+ trackId: track.id,
+ elementId: element.id,
+ };
+ }
+
+ return null;
+}
+
+function findKeyframeTime({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): number | null {
+ const binding = animations.bindings[propertyPath];
+ if (!binding) return null;
+
+ for (const component of binding.components) {
+ const channel = animations.channels[component.channelId];
+ if (channel?.kind !== "scalar") continue;
+ const key = channel.keys.find((k) => k.id === keyframeId);
+ if (key !== undefined) return key.time;
+ }
+
+ return null;
+}
+
+function groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+}: {
+ selectedKeyframes: SelectedKeyframeRef[];
+}) {
+ const groups = new Map<
+ string,
+ {
+ trackId: string;
+ elementId: string;
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframes: SelectedKeyframeRef[];
+ }
+ >();
+
+ for (const keyframe of selectedKeyframes) {
+ const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
+ const existingGroup = groups.get(groupKey);
+ if (existingGroup) {
+ existingGroup.keyframes.push(keyframe);
+ continue;
+ }
+
+ groups.set(groupKey, {
+ trackId: keyframe.trackId,
+ elementId: keyframe.elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframes: [keyframe],
+ });
+ }
+
+ return [...groups.values()];
+}
+
+function getComponentLabel({ componentKey }: { componentKey: string }): string {
+ switch (componentKey) {
+ case "value":
+ return "Value";
+ default:
+ return componentKey.toUpperCase();
+ }
+}
+
+/**
+ * Returns the absolute value span of the nearest non-flat adjacent segment,
+ * used as the Y-axis scale when editing a flat segment in the graph editor.
+ * Falls back to 1.0 if all surrounding segments are also flat.
+ */
+function getReferenceSpanValue({
+ context,
+}: {
+ context: ScalarGraphKeyframeContext;
+}): number {
+ const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
+ const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id);
+ const rightIndex = context.nextKey
+ ? sorted.findIndex((k) => k.id === context.nextKey?.id)
+ : -1;
+
+ for (let i = leftIndex - 1; i >= 0; i--) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+
+ if (rightIndex !== -1) {
+ for (let i = rightIndex; i < sorted.length - 1; i++) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+ }
+
+ return 1.0;
+}
+
+interface GraphEditorPropertySelection {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ secondaryKeyframeId: string | null;
+ options: GraphEditorPropertyOption[];
+}
+
+function resolvePropertySelection({
+ element,
+ propertyKeyframes,
+}: {
+ element: TimelineElement;
+ propertyKeyframes: ReturnType<
+ typeof groupSelectedKeyframesByProperty
+ >[number];
+}):
+ | GraphEditorPropertySelection
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ if (propertyKeyframes.keyframes.length > 2) {
+ return {
+ reason: "multiple-keyframes-selected",
+ message: "Select at most two adjacent keyframes per property.",
+ };
+ }
+
+ if (!element.animations) {
+ return {
+ reason: "selected-element-has-no-animations",
+ message: "The selected keyframe has no editable graph.",
+ };
+ }
+
+ const scalarResult = getEditableScalarChannels({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ });
+ if (!scalarResult || scalarResult.channels.length === 0) {
+ return {
+ reason: "selected-keyframe-has-no-scalar-channel",
+ message: "The selected keyframe has no editable graph channel.",
+ };
+ }
+
+ const primaryKeyframe = propertyKeyframes.keyframes[0];
+ let resolvedKeyframeId = primaryKeyframe.keyframeId;
+ let secondaryKeyframeId =
+ propertyKeyframes.keyframes.length === 2
+ ? propertyKeyframes.keyframes[1].keyframeId
+ : null;
+
+ if (secondaryKeyframeId !== null) {
+ const time1 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: primaryKeyframe.keyframeId,
+ });
+ const time2 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: secondaryKeyframeId,
+ });
+ if (time2 !== null && (time1 === null || time2 < time1)) {
+ resolvedKeyframeId = secondaryKeyframeId;
+ secondaryKeyframeId = primaryKeyframe.keyframeId;
+ }
+ }
+
+ const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
+ const easingMode = getEasingModeForKind(resolvedBinding.kind);
+ const contexts = scalarChannels.flatMap((channel) => {
+ const context = getScalarKeyframeContext({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ componentKey: channel.componentKey,
+ keyframeId: resolvedKeyframeId,
+ });
+ if (!context) {
+ return [];
+ }
+
+ return [
+ {
+ context,
+ option: {
+ key: channel.componentKey,
+ label: getComponentLabel({ componentKey: channel.componentKey }),
+ },
+ },
+ ];
+ });
+
+ if (contexts.length === 0) {
+ return {
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ };
+ }
+
+ // For shared-easing bindings (e.g. color), all components always use the same
+ // curve. Collapse them to a single "value" option so the key is compatible with
+ // single-component scalar bindings (e.g. opacity), enabling mixed selections.
+ const options =
+ easingMode === "shared"
+ ? [
+ {
+ key: "value",
+ label: "Curve",
+ context: contexts[0].context,
+ allContexts: contexts.map(({ context }) => context),
+ },
+ ]
+ : contexts.map(({ context, option }) => ({
+ key: option.key,
+ label: option.label,
+ context,
+ allContexts: [context],
+ }));
+
+ return {
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ secondaryKeyframeId,
+ options,
+ };
+}
+
+function isLinearCurve({
+ cubicBezier,
+}: {
+ cubicBezier: NormalizedCubicBezier;
+}): boolean {
+ return (
+ Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
+ );
+}
+
+function resolveSegmentForOption({
+ propertySelection,
+ componentKey,
+}: {
+ propertySelection: GraphEditorPropertySelection;
+ componentKey: string;
+}):
+ | {
+ segment: GraphEditorResolvedSegment;
+ }
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ const option = propertySelection.options.find(
+ (propertyOption) => propertyOption.key === componentKey,
+ );
+ if (!option) {
+ return {
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ };
+ }
+
+ if (!option.context.nextKey) {
+ return {
+ reason: "selected-keyframe-has-no-next-segment",
+ message: "Select a keyframe that has an outgoing segment.",
+ };
+ }
+
+ if (
+ propertySelection.secondaryKeyframeId !== null &&
+ option.context.nextKey.id !== propertySelection.secondaryKeyframeId
+ ) {
+ return {
+ reason: "selected-keyframes-are-not-adjacent",
+ message: "Selected keyframes must be adjacent on each property.",
+ };
+ }
+
+ if (option.context.keyframe.segmentToNext === "step") {
+ return {
+ reason: "selected-segment-is-hold",
+ message: "Hold segments have a fixed value - easing has no effect here.",
+ };
+ }
+
+ const referenceSpanValue = getReferenceSpanValue({ context: option.context });
+ const cubicBezier =
+ option.context.keyframe.segmentToNext === "linear"
+ ? GRAPH_LINEAR_CURVE
+ : getNormalizedCubicBezierForScalarSegment({
+ leftKey: option.context.keyframe,
+ rightKey: option.context.nextKey,
+ referenceSpanValue,
+ });
+ if (!cubicBezier) {
+ return {
+ reason: "selected-segment-is-flat",
+ message:
+ "Cannot edit a segment where both keyframes are at the same time.",
+ };
+ }
+
+ return {
+ segment: {
+ propertyPath: propertySelection.propertyPath,
+ keyframeId: propertySelection.keyframeId,
+ context: option.context,
+ allContexts: option.allContexts,
+ cubicBezier,
+ referenceSpanValue,
+ },
+ };
+}
+
+export function resolveGraphEditorSelectionState({
+ tracks,
+ selectedKeyframes,
+ preferredComponentKey,
+}: {
+ tracks: SceneTracks;
+ selectedKeyframes: SelectedKeyframeRef[];
+ preferredComponentKey?: string | null;
+}): GraphEditorSelectionState {
+ if (selectedKeyframes.length === 0) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const propertyKeyframes = groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+ });
+ const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
+ if (!primaryKeyframe) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const selectedElement = findElementByKeyframe({
+ tracks,
+ keyframe: primaryKeyframe,
+ });
+ if (!selectedElement) {
+ return createUnavailableState({
+ reason: "selected-element-missing",
+ message: "The selected keyframe could not be resolved.",
+ });
+ }
+
+ const spansMultipleElements = propertyKeyframes.some(
+ (propertySelection) =>
+ propertySelection.trackId !== selectedElement.trackId ||
+ propertySelection.elementId !== selectedElement.elementId,
+ );
+ if (spansMultipleElements) {
+ return createUnavailableState({
+ reason: "selected-keyframes-span-multiple-elements",
+ message: "Selected keyframes must be on the same element.",
+ });
+ }
+
+ const propertySelections = propertyKeyframes.map((propertySelection) =>
+ resolvePropertySelection({
+ element: selectedElement.element,
+ propertyKeyframes: propertySelection,
+ }),
+ );
+ const unavailablePropertySelection = propertySelections.find(
+ (propertySelection) => "reason" in propertySelection,
+ );
+ if (
+ unavailablePropertySelection &&
+ "reason" in unavailablePropertySelection
+ ) {
+ return createUnavailableState({
+ reason: unavailablePropertySelection.reason,
+ message: unavailablePropertySelection.message,
+ });
+ }
+
+ const resolvedPropertySelections = propertySelections.filter(
+ (propertySelection): propertySelection is GraphEditorPropertySelection =>
+ !("reason" in propertySelection),
+ );
+ const sharedComponentOptions =
+ resolvedPropertySelections[0]?.options.filter((componentOption) =>
+ resolvedPropertySelections.every((propertySelection) =>
+ propertySelection.options.some(
+ (option) => option.key === componentOption.key,
+ ),
+ ),
+ ) ?? [];
+ const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
+ key,
+ label,
+ }));
+ if (componentOptions.length === 0) {
+ return createUnavailableState({
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ });
+ }
+
+ // Try each component option in preference order (preferred first, then the rest)
+ // and stop at the first key where every property resolves to a valid segment.
+ // This single pass both selects the active key and produces the segment list.
+ const candidateKeys = [
+ ...(preferredComponentKey &&
+ componentOptions.some((option) => option.key === preferredComponentKey)
+ ? [preferredComponentKey]
+ : []),
+ ...componentOptions
+ .filter((option) => option.key !== preferredComponentKey)
+ .map((option) => option.key),
+ ];
+
+ let activeComponentKey = componentOptions[0].key;
+ let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
+
+ for (const candidateKey of candidateKeys) {
+ const results = resolvedPropertySelections.map((propertySelection) =>
+ resolveSegmentForOption({
+ propertySelection,
+ componentKey: candidateKey,
+ }),
+ );
+ activeComponentKey = candidateKey;
+ segmentResults = results;
+ if (results.every((result) => "segment" in result)) {
+ break;
+ }
+ }
+
+ const unavailableSegment = segmentResults.find(
+ (result) => !("segment" in result),
+ );
+ if (unavailableSegment && !("segment" in unavailableSegment)) {
+ return createUnavailableState({
+ reason: unavailableSegment.reason,
+ message: unavailableSegment.message,
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ const segments = segmentResults.flatMap((result) =>
+ "segment" in result ? [result.segment] : [],
+ );
+ const primarySegment = segments[0];
+ if (!primarySegment) {
+ return createUnavailableState({
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ return {
+ status: "ready",
+ message:
+ segments.length === 1
+ ? "Edit graph"
+ : `Edit graph for ${segments.length} properties`,
+ componentOptions,
+ activeComponentKey,
+ trackId: selectedElement.trackId,
+ elementId: selectedElement.elementId,
+ element: selectedElement.element,
+ segments,
+ cubicBezier: primarySegment.cubicBezier,
+ };
+}
+
+export function buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): GraphEditorCurvePatch[] | null {
+ if (!context.nextKey) {
+ return null;
+ }
+
+ if (isLinearCurve({ cubicBezier })) {
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "linear",
+ rightHandle: null,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: null,
+ },
+ },
+ ];
+ }
+
+ const handles = getCurveHandlesForNormalizedCubicBezier({
+ leftKey: context.keyframe,
+ rightKey: context.nextKey,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!handles) {
+ return null;
+ }
+
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "bezier",
+ rightHandle: handles.rightHandle,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: handles.leftHandle,
+ },
+ },
+ ];
+}
+
+export function applyGraphEditorCurvePreview({
+ animations,
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ animations: ElementAnimations | undefined;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): ElementAnimations | undefined {
+ const patches = buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!patches) {
+ return animations;
+ }
+
+ return patches.reduce<ElementAnimations | undefined>(
+ (nextAnimations, { keyframeId, patch }) =>
+ updateScalarKeyframeCurve({
+ animations: nextAnimations,
+ propertyPath: context.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ }),
+ animations,
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/use-controller.ts b/apps/web/src/timeline/components/graph-editor/use-controller.ts
new file mode 100644
index 00000000..ea091485
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/use-controller.ts
@@ -0,0 +1,178 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEditor } from "@/editor/use-editor";
+import { registerCanceller } from "@/editor/cancel-interaction";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
+import {
+ applyGraphEditorCurvePreview,
+ buildGraphEditorCurvePatches,
+ resolveGraphEditorSelectionState,
+ type GraphEditorSelectionState,
+} from "./session";
+
+export function useGraphEditorController() {
+ const editor = useEditor();
+ const renderTracks = useEditor(
+ (currentEditor) =>
+ currentEditor.timeline.getPreviewTracks() ??
+ currentEditor.scenes.getActiveScene().tracks,
+ );
+ const { selectedKeyframes } = useKeyframeSelection();
+ const [open, setOpen] = useState(false);
+ const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
+ null,
+ );
+ const hasPreviewRef = useRef(false);
+
+ const state = useMemo<GraphEditorSelectionState>(
+ () =>
+ resolveGraphEditorSelectionState({
+ tracks: renderTracks,
+ selectedKeyframes,
+ preferredComponentKey: activeComponentKey,
+ }),
+ [activeComponentKey, renderTracks, selectedKeyframes],
+ );
+
+ const stateKey =
+ state.status === "ready"
+ ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments
+ .map(
+ (segment) =>
+ `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`,
+ )
+ .join("|")}`
+ : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
+ const previousStateKeyRef = useRef(stateKey);
+
+ const discardPreview = useCallback(() => {
+ if (!hasPreviewRef.current) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ hasPreviewRef.current = false;
+ }, [editor]);
+
+ useEffect(() => {
+ if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
+ discardPreview();
+ }
+
+ previousStateKeyRef.current = stateKey;
+ }, [discardPreview, stateKey]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ return registerCanceller({
+ fn: () => {
+ discardPreview();
+ setOpen(false);
+ },
+ });
+ }, [discardPreview, open]);
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (!nextOpen) {
+ discardPreview();
+ }
+
+ setOpen(nextOpen);
+ },
+ [discardPreview],
+ );
+
+ const handleActiveComponentKeyChange = useCallback(
+ (nextComponentKey: string) => {
+ discardPreview();
+ setActiveComponentKey(nextComponentKey);
+ },
+ [discardPreview],
+ );
+
+ const handlePreviewValue = useCallback(
+ (nextValue: NormalizedCubicBezier) => {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const nextAnimations = state.segments.reduce(
+ (animations, segment) =>
+ segment.allContexts.reduce(
+ (nextAnimationsForSegment, context) =>
+ applyGraphEditorCurvePreview({
+ animations: nextAnimationsForSegment,
+ context,
+ cubicBezier: nextValue,
+ referenceSpanValue: segment.referenceSpanValue,
+ }),
+ animations,
+ ),
+ state.element.animations,
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: state.trackId,
+ elementId: state.elementId,
+ updates: { animations: nextAnimations },
+ },
+ ],
+ });
+ hasPreviewRef.current = true;
+ },
+ [editor, state],
+ );
+
+ const handleCommitValue = useCallback(
+ (nextValue: NormalizedCubicBezier) => {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ editor.timeline.updateKeyframeCurves({
+ keyframes: state.segments.flatMap((segment) => {
+ const patches = buildGraphEditorCurvePatches({
+ context: segment.context,
+ cubicBezier: nextValue,
+ referenceSpanValue: segment.referenceSpanValue,
+ });
+ if (!patches) {
+ return [];
+ }
+
+ return segment.allContexts.flatMap((context) =>
+ patches.map(({ keyframeId, patch }) => ({
+ trackId: state.trackId,
+ elementId: state.elementId,
+ propertyPath: segment.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ })),
+ );
+ }),
+ });
+ hasPreviewRef.current = false;
+ },
+ [editor, state],
+ );
+
+ return {
+ open,
+ onOpenChange: handleOpenChange,
+ canOpen: state.status === "ready",
+ tooltip: state.status === "ready" ? "Open graph editor" : state.message,
+ state,
+ onActiveComponentKeyChange: handleActiveComponentKeyChange,
+ onPreviewValue: handlePreviewValue,
+ onCommitValue: handleCommitValue,
+ onCancelPreview: discardPreview,
+ };
+}