diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/graph-editor/popover.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/graph-editor/popover.tsx')
| -rw-r--r-- | apps/web/src/timeline/components/graph-editor/popover.tsx | 334 |
1 files changed, 334 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx new file mode 100644 index 00000000..1b8194c0 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + type EasingPreset, +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + <Popover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + <PopoverContent + side={side} + sideOffset={8} + className="w-60 overflow-hidden px-0" + > + {componentOptions.length > 1 && ( + <div className="border-b px-3 py-2"> + <div className="bg-muted/40 inline-flex rounded-md p-0.5"> + {componentOptions.map((component) => ( + <button + key={component.key} + type="button" + onClick={() => onActiveComponentKeyChange?.(component.key)} + className={cn( + "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium", + activeComponentKey === component.key + ? "bg-background text-foreground shadow-xs" + : "text-muted-foreground hover:text-foreground", + )} + > + {component.label} + </button> + ))} + </div> + </div> + )} + + <div className="px-3 py-3"> + {value ? ( + <BezierGraph + value={value} + onChange={onPreviewValue} + onChangeEnd={onCommitValue} + onCancel={onCancelPreview} + /> + ) : ( + <GraphEditorEmptyState message={message} /> + )} + </div> + + <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2"> + <TabsList className="px-3"> + <TabsTrigger value="presets" className="text-xs"> + Presets + </TabsTrigger> + <TabsTrigger value="saved" className="text-xs"> + Saved + </TabsTrigger> + </TabsList> + <TabsContent value="presets" className="px-3 pb-0"> + <ExpandableGrid + isExpanded={isExpanded} + shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + /> + ))} + </ExpandableGrid> + </TabsContent> + <TabsContent value="saved" className="px-3"> + <div className="grid grid-cols-3 gap-1"> + {custom.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + onDelete={() => removePreset({ id: preset.id })} + /> + ))} + <button + type="button" + onClick={() => value && savePreset({ value })} + disabled={!canEdit} + className={cn( + "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1", + canEdit + ? "hover:bg-foreground/5 cursor-pointer" + : "cursor-not-allowed opacity-50", + )} + > + <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed"> + <HugeiconsIcon + icon={PlusSignIcon} + className="size-3.5 opacity-40" + /> + </div> + <span className="text-[10px] leading-tight">Save</span> + </button> + </div> + </TabsContent> + </Tabs> + </PopoverContent> + </Popover> + ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( + <div + style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }} + className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed" + > + {message} + </div> + ); +} + +function ExpandableGrid({ + children, + isExpanded, + shouldExpand, + onExpand, +}: { + children: React.ReactNode; + isExpanded: boolean; + shouldExpand: boolean; + onExpand: () => void; +}) { + const gridStyle = shouldExpand + ? isExpanded + ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } + : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } + : undefined; + + return ( + <div className="relative"> + <div className="grid grid-cols-3 gap-1" style={gridStyle}> + {children} + </div> + {!isExpanded && shouldExpand && ( + <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b"> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={onExpand} + > + <HugeiconsIcon + icon={ArrowDown01Icon} + className="text-muted-foreground size-3" + /> + </Button> + </div> + )} + </div> + ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + <button + type="button" + onClick={onSelect} + disabled={disabled} + className={cn( + "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1", + disabled + ? "cursor-not-allowed opacity-50" + : "hover:bg-foreground/5 cursor-pointer", + isActive && "bg-primary/5! text-primary", + )} + > + <div + className={cn( + "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5", + isActive && "bg-primary/5!", + )} + > + <CurveThumb value={preset.value} /> + </div> + <span + className={cn( + "text-[10px] leading-tight", + isActive ? "text-primary" : "text-muted-foreground", + )} + > + {preset.label} + </span> + {onDelete && ( + <Button + variant="destructive" + size="icon" + className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex" + onClick={(event) => { + event.stopPropagation(); + onDelete(); + }} + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + )} + </button> + ); +} + +function toThumbX({ value }: { value: number }) { + return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2); +} + +function toThumbY({ value }: { value: number }) { + return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) }); + const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return ( + <svg + width={THUMB_WIDTH} + height={THUMB_HEIGHT} + viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`} + > + <title>Curve preset preview</title> + <path + d={`M${points.join("L")}`} + fill="none" + className="stroke-current" + strokeWidth={1.5} + strokeLinecap="round" + /> + </svg> + ); +} |
