summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/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/components/editor/panels/timeline/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/components/editor/panels/timeline/graph-editor')
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx235
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx334
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts704
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts178
6 files changed, 0 insertions, 1571 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
deleted file mode 100644
index 3f78350b..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-"use client";
-
-import { useRef, useState, type PointerEvent } from "react";
-import { useShiftKey } from "@/hooks/use-shift-key";
-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;
- 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/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
deleted file mode 100644
index 684ac7c9..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { useSyncExternalStore } from "react";
-import { generateUUID } from "@/utils/id";
-import type { NormalizedCubicBezier } from "@/lib/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/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
deleted file mode 100644
index 6ccc9071..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { NormalizedCubicBezier } from "@/lib/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/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
deleted file mode 100644
index 733195ed..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx
+++ /dev/null
@@ -1,334 +0,0 @@
-"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,
- 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/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
deleted file mode 100644
index cc0fb070..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts
+++ /dev/null
@@ -1,704 +0,0 @@
-import {
- getCurveHandlesForNormalizedCubicBezier,
- getEditableScalarChannels,
- getEasingModeForKind,
- getNormalizedCubicBezierForScalarSegment,
- getScalarKeyframeContext,
- updateScalarKeyframeCurve,
-} from "@/lib/animation";
-import type {
- AnimationPath,
- ElementAnimations,
- NormalizedCubicBezier,
- ScalarCurveKeyframePatch,
- ScalarGraphKeyframeContext,
- SelectedKeyframeRef,
-} from "@/lib/animation/types";
-import type { SceneTracks, TimelineElement } 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-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/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
deleted file mode 100644
index 51d5a0ff..00000000
--- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-"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.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,
- };
-}