| 1 | import { useRef, useEffect, useCallback, useState } from "react"; |
| 2 | import type { CurvePoint, CurveChannel, CurvesState } from "../lib/types"; |
| 3 | import { interpolateSpline } from "../lib/curves"; |
| 4 | |
| 5 | interface CurvesEditorProps { |
| 6 | curves: CurvesState; |
| 7 | onChange: (channel: CurveChannel, points: CurvePoint[]) => void; |
| 8 | } |
| 9 | |
| 10 | const SIZE = 256; |
| 11 | const HANDLE_RADIUS = 6; |
| 12 | const CHANNELS: CurveChannel[] = ["rgb", "r", "g", "b"]; |
| 13 | const CHANNEL_LABELS: Record<CurveChannel, string> = { |
| 14 | rgb: "RGB", |
| 15 | r: "R", |
| 16 | g: "G", |
| 17 | b: "B", |
| 18 | }; |
| 19 | |
| 20 | export function CurvesEditor({ curves, onChange }: CurvesEditorProps) { |
| 21 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| 22 | const [activeChannel, setActiveChannel] = useState<CurveChannel>("rgb"); |
| 23 | const draggingRef = useRef<number | null>(null); |
| 24 | |
| 25 | const points = curves[activeChannel]; |
| 26 | |
| 27 | const draw = useCallback(() => { |
| 28 | const canvas = canvasRef.current; |
| 29 | if (!canvas) return; |
| 30 | const ctx = canvas.getContext("2d"); |
| 31 | if (!ctx) return; |
| 32 | |
| 33 | const dpr = window.devicePixelRatio || 1; |
| 34 | const w = SIZE; |
| 35 | const h = SIZE; |
| 36 | |
| 37 | canvas.width = w * dpr; |
| 38 | canvas.height = h * dpr; |
| 39 | ctx.scale(dpr, dpr); |
| 40 | |
| 41 | // Background |
| 42 | ctx.fillStyle = "#1e1c1f"; |
| 43 | ctx.fillRect(0, 0, w, h); |
| 44 | |
| 45 | // Grid |
| 46 | ctx.strokeStyle = "#333"; |
| 47 | ctx.lineWidth = 0.5; |
| 48 | for (let i = 1; i < 4; i++) { |
| 49 | const pos = (i / 4) * w; |
| 50 | ctx.beginPath(); |
| 51 | ctx.moveTo(pos, 0); |
| 52 | ctx.lineTo(pos, h); |
| 53 | ctx.stroke(); |
| 54 | ctx.beginPath(); |
| 55 | ctx.moveTo(0, pos); |
| 56 | ctx.lineTo(w, pos); |
| 57 | ctx.stroke(); |
| 58 | } |
| 59 | |
| 60 | // Diagonal reference |
| 61 | ctx.strokeStyle = "#555"; |
| 62 | ctx.lineWidth = 1; |
| 63 | ctx.setLineDash([4, 4]); |
| 64 | ctx.beginPath(); |
| 65 | ctx.moveTo(0, h); |
| 66 | ctx.lineTo(w, 0); |
| 67 | ctx.stroke(); |
| 68 | ctx.setLineDash([]); |
| 69 | |
| 70 | // Curve |
| 71 | const lut = interpolateSpline(points); |
| 72 | ctx.strokeStyle = "#ffffff"; |
| 73 | ctx.lineWidth = 1.5; |
| 74 | ctx.beginPath(); |
| 75 | for (let x = 0; x < 256; x++) { |
| 76 | const px = (x / 255) * w; |
| 77 | const py = h - (lut[x] / 255) * h; |
| 78 | if (x === 0) ctx.moveTo(px, py); |
| 79 | else ctx.lineTo(px, py); |
| 80 | } |
| 81 | ctx.stroke(); |
| 82 | |
| 83 | // Control points |
| 84 | for (const point of points) { |
| 85 | const px = (point.x / 255) * w; |
| 86 | const py = h - (point.y / 255) * h; |
| 87 | ctx.fillStyle = "#121113"; |
| 88 | ctx.strokeStyle = "#ffffff"; |
| 89 | ctx.lineWidth = 1.5; |
| 90 | ctx.beginPath(); |
| 91 | ctx.arc(px, py, HANDLE_RADIUS, 0, Math.PI * 2); |
| 92 | ctx.fill(); |
| 93 | ctx.stroke(); |
| 94 | } |
| 95 | }, [points]); |
| 96 | |
| 97 | useEffect(() => { |
| 98 | draw(); |
| 99 | }, [draw]); |
| 100 | |
| 101 | function canvasToPoint(e: React.PointerEvent): { x: number; y: number } { |
| 102 | const canvas = canvasRef.current!; |
| 103 | const rect = canvas.getBoundingClientRect(); |
| 104 | const x = ((e.clientX - rect.left) / rect.width) * 255; |
| 105 | const y = (1 - (e.clientY - rect.top) / rect.height) * 255; |
| 106 | return { x: Math.round(x), y: Math.round(Math.max(0, Math.min(255, y))) }; |
| 107 | } |
| 108 | |
| 109 | function findNearestPoint(cx: number, cy: number): number | null { |
| 110 | const threshold = 15; |
| 111 | let bestIdx: number | null = null; |
| 112 | let bestDist = Infinity; |
| 113 | for (let i = 0; i < points.length; i++) { |
| 114 | const dx = points[i].x - cx; |
| 115 | const dy = points[i].y - cy; |
| 116 | const dist = Math.sqrt(dx * dx + dy * dy); |
| 117 | if (dist < threshold && dist < bestDist) { |
| 118 | bestDist = dist; |
| 119 | bestIdx = i; |
| 120 | } |
| 121 | } |
| 122 | return bestIdx; |
| 123 | } |
| 124 | |
| 125 | function handlePointerDown(e: React.PointerEvent) { |
| 126 | const { x, y } = canvasToPoint(e); |
| 127 | const idx = findNearestPoint(x, y); |
| 128 | |
| 129 | if (idx !== null) { |
| 130 | draggingRef.current = idx; |
| 131 | } else { |
| 132 | // Add new point |
| 133 | const newPoints = [...points, { x, y }].sort((a, b) => a.x - b.x); |
| 134 | onChange(activeChannel, newPoints); |
| 135 | const newIdx = newPoints.findIndex((p) => p.x === x && p.y === y); |
| 136 | draggingRef.current = newIdx; |
| 137 | } |
| 138 | |
| 139 | (e.target as Element).setPointerCapture(e.pointerId); |
| 140 | } |
| 141 | |
| 142 | function handlePointerMove(e: React.PointerEvent) { |
| 143 | if (draggingRef.current === null) return; |
| 144 | const { y } = canvasToPoint(e); |
| 145 | const idx = draggingRef.current; |
| 146 | |
| 147 | const updated = [...points]; |
| 148 | // Endpoints can only move vertically |
| 149 | if (idx === 0 || idx === points.length - 1) { |
| 150 | updated[idx] = { ...updated[idx], y }; |
| 151 | } else { |
| 152 | const rect = canvasRef.current!.getBoundingClientRect(); |
| 153 | const rawX = ((e.clientX - rect.left) / rect.width) * 255; |
| 154 | const x = Math.round(Math.max(updated[idx - 1].x + 1, Math.min(updated[idx + 1].x - 1, rawX))); |
| 155 | updated[idx] = { x, y }; |
| 156 | } |
| 157 | onChange(activeChannel, updated); |
| 158 | } |
| 159 | |
| 160 | function handlePointerUp() { |
| 161 | draggingRef.current = null; |
| 162 | } |
| 163 | |
| 164 | function handleDoubleClick(e: React.MouseEvent) { |
| 165 | const canvas = canvasRef.current!; |
| 166 | const rect = canvas.getBoundingClientRect(); |
| 167 | const cx = ((e.clientX - rect.left) / rect.width) * 255; |
| 168 | const cy = (1 - (e.clientY - rect.top) / rect.height) * 255; |
| 169 | const idx = findNearestPoint(cx, cy); |
| 170 | if (idx !== null && idx !== 0 && idx !== points.length - 1) { |
| 171 | const updated = points.filter((_, i) => i !== idx); |
| 172 | onChange(activeChannel, updated); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | return ( |
| 177 | <div className="curves-editor"> |
| 178 | <div className="curves-tabs"> |
| 179 | {CHANNELS.map((ch) => ( |
| 180 | <button |
| 181 | key={ch} |
| 182 | className={`curves-tab ${activeChannel === ch ? "active" : ""}`} |
| 183 | onClick={() => setActiveChannel(ch)} |
| 184 | > |
| 185 | {CHANNEL_LABELS[ch]} |
| 186 | </button> |
| 187 | ))} |
| 188 | </div> |
| 189 | <canvas |
| 190 | ref={canvasRef} |
| 191 | className="curves-canvas" |
| 192 | style={{ width: SIZE, height: SIZE }} |
| 193 | onPointerDown={handlePointerDown} |
| 194 | onPointerMove={handlePointerMove} |
| 195 | onPointerUp={handlePointerUp} |
| 196 | onDoubleClick={handleDoubleClick} |
| 197 | /> |
| 198 | </div> |
| 199 | ); |
| 200 | } |