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 --- .../timeline/components/graph-editor/popover.tsx | 334 +++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 apps/web/src/timeline/components/graph-editor/popover.tsx (limited to 'apps/web/src/timeline/components/graph-editor/popover.tsx') diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx new file mode 100644 index 00000000..1b8194c0 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + type EasingPreset, +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + { + 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 + + + ); +} -- cgit v1.3.1