"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 (
);
}