diff --git a/src/components/CubicBezierEditor.tsx b/src/components/CubicBezierEditor.tsx new file mode 100644 index 00000000..56b7ecbf --- /dev/null +++ b/src/components/CubicBezierEditor.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +interface CubicBezierEditorProps { + value: [number, number, number, number]; + onChange: (value: [number, number, number, number]) => void; + onCommit?: (value: [number, number, number, number]) => void; + disabled?: boolean; +} + +const clamp = (value: number) => Math.max(0, Math.min(1, value)); + +// Hardcoded dark-theme palette (node-banana is dark mode only) +const palette = { + background: "#171717", // neutral-900 + border: "rgba(255,255,255,0.12)", + muted: "rgba(255,255,255,0.35)", + primary: "#f59e0b", // amber-500 +}; + +const now = () => + typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); + +function CubicBezierEditorComponent({ + value, + onChange, + onCommit, + disabled = false, +}: CubicBezierEditorProps) { + const editorRef = useRef(null); + const valueRef = useRef(value); + const draggingHandleRef = useRef<"p1" | "p2" | null>(null); + const [draggingHandle, setDraggingHandle] = useState<"p1" | "p2" | null>(null); + const commitFrameRef = useRef(null); + const pendingValueRef = useRef<{ + value: [number, number, number, number]; + capturedAt: number; + } | null>(null); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + const flushPendingChange = useCallback(() => { + commitFrameRef.current = null; + if (!pendingValueRef.current) return; + const { value: nextValue } = pendingValueRef.current; + onChange(nextValue); + pendingValueRef.current = null; + }, [onChange]); + + const scheduleCommit = useCallback(() => { + if (commitFrameRef.current !== null) return; + commitFrameRef.current = window.requestAnimationFrame(flushPendingChange); + }, [flushPendingChange]); + + useEffect(() => { + return () => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + } + }; + }, []); + + const updateHandleFromClient = useCallback( + (handle: "p1" | "p2", clientX: number, clientY: number) => { + const container = editorRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const width = rect.width || 260; + const height = rect.height || 260; + const normalizedX = clamp((clientX - rect.left) / width); + const normalizedY = clamp(1 - (clientY - rect.top) / height); + const current = valueRef.current; + const nextValue: [number, number, number, number] = + handle === "p1" + ? [normalizedX, normalizedY, current[2], current[3]] + : [current[0], current[1], normalizedX, normalizedY]; + pendingValueRef.current = { + value: nextValue, + capturedAt: now(), + }; + valueRef.current = nextValue; + scheduleCommit(); + }, + [scheduleCommit] + ); + + const startDragging = useCallback( + (handle: "p1" | "p2", event: React.PointerEvent) => { + if (disabled) return; + event.preventDefault(); + draggingHandleRef.current = handle; + setDraggingHandle(handle); + updateHandleFromClient(handle, event.clientX, event.clientY); + }, + [disabled, updateHandleFromClient] + ); + + // Window-level pointer listeners for smooth drag across entire viewport + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + const handle = draggingHandleRef.current; + if (!handle || disabled) return; + event.preventDefault(); + updateHandleFromClient(handle, event.clientX, event.clientY); + }; + + const stopDragging = () => { + if (!draggingHandleRef.current) return; + draggingHandleRef.current = null; + setDraggingHandle(null); + if (onCommit) { + onCommit(valueRef.current); + } + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopDragging); + window.addEventListener("pointercancel", stopDragging); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopDragging); + window.removeEventListener("pointercancel", stopDragging); + }; + }, [disabled, onCommit, updateHandleFromClient]); + + const controlStyles = useMemo( + () => ({ + p1: { + left: `${(value[0] * 100).toFixed(2)}%`, + top: `${((1 - value[1]) * 100).toFixed(2)}%`, + }, + p2: { + left: `${(value[2] * 100).toFixed(2)}%`, + top: `${((1 - value[3]) * 100).toFixed(2)}%`, + }, + }), + [value] + ); + + const svgPoints = useMemo( + () => ({ + start: { x: 0, y: 100 }, + end: { x: 100, y: 0 }, + c1: { x: value[0] * 100, y: (1 - value[1]) * 100 }, + c2: { x: value[2] * 100, y: (1 - value[3]) * 100 }, + }), + [value] + ); + + const curvePath = useMemo( + () => + `M${svgPoints.start.x} ${svgPoints.start.y} C ${svgPoints.c1.x} ${svgPoints.c1.y}, ${svgPoints.c2.x} ${svgPoints.c2.y}, ${svgPoints.end.x} ${svgPoints.end.y}`, + [svgPoints] + ); + + return ( +
+
+ + {/* Control point 1 - nodrag nopan touch-none prevents React Flow node dragging */} +
+
+ ); +} + +export const CubicBezierEditor = memo(CubicBezierEditorComponent);