"use client"; import { useRef, useState, type PointerEvent } from "react"; import { useShiftKey } from "@/hooks/use-shift-key"; import { getBezierPoint } from "@/animation/bezier"; import type { NormalizedCubicBezier } from "@/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 ); }