From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../panels/timeline/graph-editor/bezier-graph.tsx | 235 ------- .../timeline/graph-editor/custom-presets-store.ts | 101 --- .../panels/timeline/graph-editor/easing-presets.ts | 19 - .../panels/timeline/graph-editor/popover.tsx | 334 ---------- .../editor/panels/timeline/graph-editor/session.ts | 704 --------------------- .../panels/timeline/graph-editor/use-controller.ts | 178 ------ 6 files changed, 1571 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/session.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts (limited to 'apps/web/src/components/editor/panels/timeline/graph-editor') 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(null); - const [activeHandle, setActiveHandle] = useState(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) => { - event.preventDefault(); - event.stopPropagation(); - setActiveHandle(handle); - event.currentTarget.setPointerCapture(event.pointerId); - }; - } - - function onPointerMove({ event }: { event: PointerEvent }) { - 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 ( - onPointerMove({ event })} - onPointerUp={onPointerUp} - onPointerCancel={onPointerCancel} - > - Bezier curve editor - - - - - - - - - - ); -} 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 ( - { - if (!nextOpen) { - onCancelPreview?.(); - } - onOpenChange?.(nextOpen); - }} - > - {children} - - {componentOptions.length > 1 && ( -
-
- {componentOptions.map((component) => ( - - ))} -
-
- )} - -
- {value ? ( - - ) : ( - - )} -
- - - - - Presets - - - Saved - - - - COLLAPSED_MAX} - onExpand={() => setIsExpanded(true)} - > - {BUILTIN_PRESETS.map((preset) => ( - onCommitValue?.(preset.value)} - /> - ))} - - - -
- {custom.map((preset) => ( - onCommitValue?.(preset.value)} - onDelete={() => removePreset({ id: preset.id })} - /> - ))} - -
-
-
-
-
- ); -} - -function GraphEditorEmptyState({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - -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 ( -
-
- {children} -
- {!isExpanded && shouldExpand && ( -
- -
- )} -
- ); -} - -function PresetItem({ - preset, - isActive, - onSelect, - onDelete, - disabled, -}: { - preset: EasingPreset; - isActive: boolean; - onSelect: () => void; - onDelete?: () => void; - disabled?: boolean; -}) { - return ( - - )} - - ); -} - -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 ( - - Curve preset preview - - - ); -} 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[] = []; - - 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( - (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( - null, - ); - const hasPreviewRef = useRef(false); - - const state = useMemo( - () => - 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, - }; -} -- cgit v1.3.1