Browse Source
- Interactive SVG Bezier curve editor with draggable control points - Hardcoded dark-theme palette (removed theme/palette system) - Removed coordinate display and debug logging - nodrag nopan touch-none classes on handle buttons for React Flow isolation - Window-level pointer listeners for smooth cross-viewport dragging - RAF-batched updates via pendingValueRef + scheduleCommit pattern - Memoized component export for render optimization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 238 additions and 0 deletions
@ -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<HTMLDivElement | null>(null); |
||||
|
const valueRef = useRef(value); |
||||
|
const draggingHandleRef = useRef<"p1" | "p2" | null>(null); |
||||
|
const [draggingHandle, setDraggingHandle] = useState<"p1" | "p2" | null>(null); |
||||
|
const commitFrameRef = useRef<number | null>(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<HTMLButtonElement>) => { |
||||
|
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 ( |
||||
|
<div className="bg-neutral-900/50 rounded-lg border border-neutral-700 p-2"> |
||||
|
<div ref={editorRef} className="relative w-full aspect-square"> |
||||
|
<svg |
||||
|
viewBox="0 0 100 100" |
||||
|
preserveAspectRatio="none" |
||||
|
className="absolute inset-0 h-full w-full select-none" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<rect |
||||
|
x="0" |
||||
|
y="0" |
||||
|
width="100" |
||||
|
height="100" |
||||
|
fill={palette.background} |
||||
|
stroke={palette.border} |
||||
|
strokeWidth="0.8" |
||||
|
rx="2" |
||||
|
/> |
||||
|
{/* Grid lines */} |
||||
|
<g stroke={palette.border} strokeWidth="0.4" strokeOpacity="0.6"> |
||||
|
<line x1="0" y1="50" x2="100" y2="50" /> |
||||
|
<line x1="50" y1="0" x2="50" y2="100" /> |
||||
|
</g> |
||||
|
{/* Diagonal reference line */} |
||||
|
<line |
||||
|
x1="0" |
||||
|
y1="100" |
||||
|
x2="100" |
||||
|
y2="0" |
||||
|
stroke={palette.border} |
||||
|
strokeWidth="0.6" |
||||
|
strokeDasharray="4 4" |
||||
|
strokeOpacity="0.7" |
||||
|
/> |
||||
|
{/* Control point lines */} |
||||
|
<g stroke={palette.muted} strokeWidth="0.8"> |
||||
|
<line x1="0" y1="100" x2={svgPoints.c1.x} y2={svgPoints.c1.y} /> |
||||
|
<line x1="100" y1="0" x2={svgPoints.c2.x} y2={svgPoints.c2.y} /> |
||||
|
</g> |
||||
|
{/* Bezier curve */} |
||||
|
<path |
||||
|
d={curvePath} |
||||
|
fill="none" |
||||
|
stroke={palette.primary} |
||||
|
strokeWidth="1.5" |
||||
|
strokeLinecap="round" |
||||
|
/> |
||||
|
</svg> |
||||
|
{/* Control point 1 - nodrag nopan touch-none prevents React Flow node dragging */} |
||||
|
<button |
||||
|
type="button" |
||||
|
aria-label="Adjust control point 1" |
||||
|
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-amber-500/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${ |
||||
|
draggingHandle === "p1" ? "ring-2 ring-amber-500/80" : "" |
||||
|
}`}
|
||||
|
style={controlStyles.p1} |
||||
|
onPointerDown={(event) => startDragging("p1", event)} |
||||
|
disabled={disabled} |
||||
|
/> |
||||
|
{/* Control point 2 - nodrag nopan touch-none prevents React Flow node dragging */} |
||||
|
<button |
||||
|
type="button" |
||||
|
aria-label="Adjust control point 2" |
||||
|
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-amber-500/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${ |
||||
|
draggingHandle === "p2" ? "ring-2 ring-amber-500/80" : "" |
||||
|
}`}
|
||||
|
style={controlStyles.p2} |
||||
|
onPointerDown={(event) => startDragging("p2", event)} |
||||
|
disabled={disabled} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export const CubicBezierEditor = memo(CubicBezierEditorComponent); |
||||
Loading…
Reference in new issue