summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/graph-editor
diff options
context:
space:
mode:
authorMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
committerMaze <mazewinther@gmail.com>2026-04-06 01:13:14 +0200
commitdedb13546a16235e7d6649961d0c9b235f62aa34 (patch)
tree08b6e14bf6b28b4c8dc8bc9f849246c75f921593 /apps/web/src/components/editor/panels/timeline/graph-editor
parent099746633183cd061fc89a63aeb9fcfb909f6db2 (diff)
downloadopencut-dedb13546a16235e7d6649961d0c9b235f62aa34.tar.gz
feat: graphs popover
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/graph-editor')
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx249
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx328
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts90
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts448
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts153
5 files changed, 1268 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
new file mode 100644
index 00000000..32f39174
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useEffect, useRef, useState, type PointerEvent } from "react";
+import { getBezierPoint } from "@/lib/animation/bezier";
+import type { NormalizedCubicBezier } from "@/lib/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;
+ points.push(
+ `${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`,
+ );
+ }
+ 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 = useRef(false);
+ const latestValueRef = useRef(value);
+
+ latestValueRef.current = value;
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Shift") isShiftPressedRef.current = true;
+ };
+ const onKeyUp = (event: KeyboardEvent) => {
+ if (event.key === "Shift") isShiftPressedRef.current = false;
+ };
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("keyup", onKeyUp);
+ };
+ }, []);
+
+ 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/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
new file mode 100644
index 00000000..86665392
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
@@ -0,0 +1,328 @@
+"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 "@/lib/animation/bezier";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+import type { GraphEditorComponentOption } from "./session";
+import {
+ BUILTIN_PRESETS,
+ PRESET_MATCH_TOLERANCE,
+ removePreset,
+ savePreset,
+ type EasingPreset,
+ useCustomPresets,
+} from "./presets";
+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(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 CurveThumb({ value }: { value: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= THUMB_SEGMENTS; i++) {
+ const progress = i / THUMB_SEGMENTS;
+ points.push(
+ `${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`,
+ );
+ }
+ 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/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
new file mode 100644
index 00000000..cbcdf566
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts
@@ -0,0 +1,90 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+
+const STORAGE_KEY = "opencut:graph-editor-presets";
+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] },
+];
+
+let cachedPresets: EasingPreset[] | null = null;
+const listeners = new Set<() => void>();
+
+function readFromStorage(): EasingPreset[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ // JSON.parse can throw if the stored value is corrupted
+ return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeToStorage(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 subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (event.key === STORAGE_KEY) notify();
+ });
+}
+
+export function useCustomPresets(): EasingPreset[] {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function savePreset(value: NormalizedCubicBezier): void {
+ const current = getSnapshot();
+ writeToStorage([
+ ...current,
+ {
+ id: `custom-${Date.now()}`,
+ label: `Custom ${current.length + 1}`,
+ value,
+ isCustom: true,
+ },
+ ]);
+ notify();
+}
+
+export function removePreset(id: string): void {
+ writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
+ notify();
+}
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
new file mode 100644
index 00000000..148fa7b4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
@@ -0,0 +1,448 @@
+"use client";
+
+import {
+ getCurveHandlesForNormalizedCubicBezier,
+ getEditableScalarChannels,
+ getNormalizedCubicBezierForScalarSegment,
+ getScalarKeyframeContext,
+ updateScalarKeyframeCurve,
+} from "@/lib/animation";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ NormalizedCubicBezier,
+ ScalarCurveKeyframePatch,
+ ScalarGraphKeyframeContext,
+ SelectedKeyframeRef,
+} from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/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-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 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;
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ element: TimelineElement;
+ context: ScalarGraphKeyframeContext;
+ 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: TimelineTrack[];
+ keyframe: SelectedKeyframeRef;
+}): { element: TimelineElement; trackId: string; elementId: string } | null {
+ for (const track of tracks) {
+ 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 getComponentLabel({ componentKey }: { componentKey: string }): string {
+ switch (componentKey) {
+ case "value":
+ return "Value";
+ default:
+ return componentKey.toUpperCase();
+ }
+}
+
+function isFlatSegment({
+ context,
+}: {
+ context: ScalarGraphKeyframeContext;
+}): boolean {
+ if (!context.nextKey) {
+ return true;
+ }
+
+ return (
+ Math.abs(context.nextKey.value - context.keyframe.value) <=
+ FLAT_VALUE_EPSILON
+ );
+}
+
+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
+ );
+}
+
+export function resolveGraphEditorSelectionState({
+ tracks,
+ selectedKeyframes,
+ preferredComponentKey,
+}: {
+ tracks: TimelineTrack[];
+ selectedKeyframes: SelectedKeyframeRef[];
+ preferredComponentKey?: string | null;
+}): GraphEditorSelectionState {
+ if (selectedKeyframes.length === 0) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ if (selectedKeyframes.length > 2) {
+ return createUnavailableState({
+ reason: "multiple-keyframes-selected",
+ message: "Select one or two adjacent keyframes to edit a curve.",
+ });
+ }
+
+ if (selectedKeyframes.length === 2) {
+ const [kf1, kf2] = selectedKeyframes;
+ if (
+ kf1.trackId !== kf2.trackId ||
+ kf1.elementId !== kf2.elementId ||
+ kf1.propertyPath !== kf2.propertyPath
+ ) {
+ return createUnavailableState({
+ reason: "multiple-keyframes-selected",
+ message: "Selected keyframes must be on the same element and property.",
+ });
+ }
+ }
+
+ const primaryKeyframe = selectedKeyframes[0];
+ const secondaryKeyframeId =
+ selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
+
+ const selectedElement = findElementByKeyframe({
+ tracks,
+ keyframe: primaryKeyframe,
+ });
+ if (!selectedElement) {
+ return createUnavailableState({
+ reason: "selected-element-missing",
+ message: "The selected keyframe could not be resolved.",
+ });
+ }
+
+ if (!selectedElement.element.animations) {
+ return createUnavailableState({
+ reason: "selected-element-has-no-animations",
+ message: "The selected keyframe has no editable graph.",
+ });
+ }
+
+ const scalarChannels = getEditableScalarChannels({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ });
+ if (scalarChannels.length === 0) {
+ return createUnavailableState({
+ reason: "selected-keyframe-has-no-scalar-channel",
+ message: "The selected keyframe has no editable graph channel.",
+ });
+ }
+
+ // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
+ // anchor so the graph editor edits the curve between the two selected keyframes.
+ let resolvedKeyframeId = primaryKeyframe.keyframeId;
+ if (secondaryKeyframeId !== null) {
+ const time1 = findKeyframeTime({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: primaryKeyframe.keyframeId,
+ });
+ const time2 = findKeyframeTime({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: secondaryKeyframeId,
+ });
+ if (time2 !== null && (time1 === null || time2 < time1)) {
+ resolvedKeyframeId = secondaryKeyframeId;
+ }
+ }
+
+ const contexts = scalarChannels.flatMap((channel) => {
+ const context = getScalarKeyframeContext({
+ animations: selectedElement.element.animations,
+ propertyPath: primaryKeyframe.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 createUnavailableState({
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ });
+ }
+
+ const nextSegmentContexts = contexts.filter(
+ ({ context }) => context.nextKey !== null,
+ );
+ const preferredContext =
+ contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
+ const activeContext =
+ preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
+ const componentOptions = contexts.map(({ option }) => option);
+
+ if (!activeContext.context.nextKey) {
+ return createUnavailableState({
+ reason: "selected-keyframe-has-no-next-segment",
+ message: "Select a keyframe that has an outgoing segment.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ if (isFlatSegment({ context: activeContext.context })) {
+ return createUnavailableState({
+ reason: "selected-segment-is-flat",
+ message: "Flat segments are not graph-editable in this popover yet.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ if (activeContext.context.keyframe.segmentToNext === "step") {
+ return createUnavailableState({
+ reason: "selected-segment-is-hold",
+ message: "Hold segments are not graph-editable in this popover yet.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ const cubicBezier =
+ activeContext.context.keyframe.segmentToNext === "linear"
+ ? GRAPH_LINEAR_CURVE
+ : getNormalizedCubicBezierForScalarSegment({
+ leftKey: activeContext.context.keyframe,
+ rightKey: activeContext.context.nextKey,
+ });
+ if (!cubicBezier) {
+ return createUnavailableState({
+ reason: "selected-segment-is-flat",
+ message: "The selected segment cannot be represented in this graph view.",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ });
+ }
+
+ return {
+ status: "ready",
+ message: "Edit graph",
+ componentOptions,
+ activeComponentKey: activeContext.option.key,
+ trackId: selectedElement.trackId,
+ elementId: selectedElement.elementId,
+ propertyPath: primaryKeyframe.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ element: selectedElement.element,
+ context: activeContext.context,
+ cubicBezier,
+ };
+}
+
+export function buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+}: {
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+}): 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,
+ });
+ 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,
+}: {
+ animations: ElementAnimations | undefined;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+}): ElementAnimations | undefined {
+ const patches = buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ });
+ 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/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
new file mode 100644
index 00000000..3b7520e6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
@@ -0,0 +1,153 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { registerCanceller } from "@/lib/cancel-interaction";
+import type { NormalizedCubicBezier } from "@/lib/animation/types";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import {
+ applyGraphEditorCurvePreview,
+ buildGraphEditorCurvePatches,
+ resolveGraphEditorSelectionState,
+ type GraphEditorSelectionState,
+} from "./session";
+
+export function useGraphEditorController() {
+ const editor = useEditor();
+ const renderTracks = useEditor((currentEditor) =>
+ currentEditor.timeline.getRenderTracks(),
+ );
+ 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.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
+ : `${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],
+ );
+
+ function handlePreviewValue(nextValue: NormalizedCubicBezier) {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const nextAnimations = applyGraphEditorCurvePreview({
+ animations: state.element.animations,
+ context: state.context,
+ cubicBezier: nextValue,
+ });
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: state.trackId,
+ elementId: state.elementId,
+ updates: {
+ animations: nextAnimations,
+ },
+ },
+ ],
+ });
+ hasPreviewRef.current = true;
+ }
+
+ function handleCommitValue(nextValue: NormalizedCubicBezier) {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const patches = buildGraphEditorCurvePatches({
+ context: state.context,
+ cubicBezier: nextValue,
+ });
+ if (!patches) {
+ return;
+ }
+
+ editor.timeline.updateKeyframeCurves({
+ keyframes: patches.map(({ keyframeId, patch }) => ({
+ trackId: state.trackId,
+ elementId: state.elementId,
+ propertyPath: state.propertyPath,
+ componentKey: state.context.componentKey,
+ keyframeId,
+ patch,
+ })),
+ });
+ hasPreviewRef.current = false;
+ }
+
+ 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,
+ };
+}