diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 0151fef..186e38d 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2789,6 +2789,9 @@ export function Workspace() { openParam === 'curve' ? ( pts.some((p) => Math.abs(p[1] - p export function ToneCurvePanel({ curve, + url, onChange, onClose, }: { curve: ToneCurve | undefined; + url: string | null; onChange: (next: ToneCurve) => void; onClose: () => void; }) { const [channel, setChannel] = useState('rgb'); + const [hist, setHist] = useState(null); + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState(false); const svgRef = useRef(null); + const boxRef = useRef(null); const drag = useRef<{ id: number; at: number } | null>(null); + const pan = useRef<{ px: number; py: number; x: number; y: number } | null>(null); + + // The distribution behind the graph is read off the render the user is looking + // at — the same sample the HISTOGRAM overlay reads — so a tone that is clipped + // on screen is a tone that has run off the end of the graph, and the two + // frames can never disagree about what the picture holds. + useEffect(() => { + if (!url) { + setHist(null); + return; + } + let live = true; + readHistogram(url, () => live) + .then((h) => { + if (live && h) setHist(h); + }) + .catch(() => undefined); + return () => { + live = false; + }; + }, [url]); const pts = curvePoints(curve, channel); const master = curvePoints(curve, 'rgb'); @@ -70,6 +103,92 @@ export function ToneCurvePanel({ return out.join(' '); }; + // The distribution, in the graph's own square: one bar per input value, the + // tallest bucket touching the top, closed along the floor so it reads as the + // picture's shape behind the line rather than a stroke across it. Scaled to its + // own tallest bucket, so the shape is the same whether the picture is a + // thousand pixels or ten million. + const histPath = (bins: number[]) => { + const max = Math.max(1, ...bins); + const step = SIZE / (bins.length - 1); + let d = ''; + for (let i = 0; i < bins.length; i++) { + d += `${i ? 'L' : 'M'}${(i * step).toFixed(2)} ${(SIZE - (bins[i] / max) * SIZE).toFixed(2)} `; + } + return `${d}L${SIZE} ${SIZE} L0 ${SIZE} Z`; + }; + + // The card is dragged by its head, and what it is dragged inside is the STAGE + // the photo sits on, not the photo: the whole point of moving it is to get it + // off the part of the picture being judged. The stage is the one box that does + // not move when the photo is re-fitted under it, so the card is parked in the + // layer's own coordinates — it rides with the photo — and only the bounds come + // from the stage. + const clampPos = (x: number, y: number) => { + const box = boxRef.current; + const layer = box?.parentElement; + const stage = box?.closest('.canvas-wrap') as HTMLElement | null; + if (!box || !layer || !stage) return { x, y }; + const l = layer.getBoundingClientRect(); + const s = stage.getBoundingClientRect(); + const b = box.getBoundingClientRect(); + const lim = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), Math.max(lo, hi)); + return { + x: lim(x, s.left - l.left + 8, s.right - l.left - b.width - 8), + y: lim(y, s.top - l.top + 8, s.bottom - l.top - b.height - 8), + }; + }; + + // A stage that shrinks — the window, or a column opening beside it — can leave + // the card outside its own bounds, where the stage's overflow would hide it and + // there would be no grabbing it back. So it is pulled inside as that box moves. + const parked = pos !== null; + useEffect(() => { + if (!parked) return; + const keep = () => setPos((p) => (p ? clampPos(p.x, p.y) : p)); + window.addEventListener('resize', keep); + const stage = boxRef.current?.closest('.canvas-wrap'); + const ro = stage ? new ResizeObserver(keep) : null; + if (stage) ro?.observe(stage); + return () => { + window.removeEventListener('resize', keep); + ro?.disconnect(); + }; + }, [parked]); + + const panStart = (e: React.PointerEvent) => { + if (e.button !== 0) return; + // The close button lives in the head and the head is the drag handle, so a + // press on it has to be left alone: capturing the pointer here would retarget + // the click that follows to the head, and the button would never hear it. + if ((e.target as Element).closest('button')) return; + const box = boxRef.current; + if (!box) return; + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + // The card's first position is the one its stylesheet parked it at; from + // then on it is the drag's own, in the layer's coordinates. + const from = pos ?? { x: box.offsetLeft, y: box.offsetTop }; + pan.current = { px: e.clientX, py: e.clientY, x: from.x, y: from.y }; + setPos(from); + setDragging(true); + }; + + const panMove = (e: React.PointerEvent) => { + const d = pan.current; + if (!d) return; + e.preventDefault(); + setPos(clampPos(d.x + (e.clientX - d.px), d.y + (e.clientY - d.py))); + }; + + const panEnd = (e: React.PointerEvent) => { + if (!pan.current) return; + pan.current = null; + setDragging(false); + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }; + const down = (e: React.PointerEvent) => { if (e.button !== 0) return; e.stopPropagation(); // the photo must not pan under the graph @@ -111,10 +230,27 @@ export function ToneCurvePanel({ }; const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4); + // What the graph is drawn against: the luminance of the picture on the grey + // channel, and that channel's own distribution when a single channel is being + // bent — the histogram follows the tab, the way Lightroom's does. + const bins = hist ? (channel === 'rgb' ? hist.lum : hist[channel]) : null; return ( -
e.stopPropagation()}> -
+
e.stopPropagation()} + > +
TONE CURVE