src/components/CurvesEditor.tsx 5.6 K raw
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
}