// Image Editor — histogram + tone-curve graph panel. // // Shown at the bottom of the editor sidebar whenever the selected tool declares // a `graph` block (see the header of javascript/image_editor_ops.js). The panel // is entirely descriptor-driven: it never names a tool, so every future // adjustment gets the graph by adding two lines to its descriptor. // // What it draws, in order: // 1. the histogram of the canvas *entering* the selected op (not the final // preview) — so the curve's input axis and the histogram share an x axis // 2. the grid, plot border and the dashed identity diagonal // 3. the tool's transfer curve: read-only when `graph.curve === 'lut'` // (derived from lut(params)), draggable when it is 'edit' // 4. `graph.handles` markers on the input axis (Levels' black/white points) // // Exported as window.ImageEditorGraph; loaded after ImageEditorParts.jsx (whose // stylesheet carries the canvas rules) and before ImageEditorPage.jsx. (function () { 'use strict'; const { Box } = MaterialUI; // Channel identity colours are the one thing here that cannot come from the // theme: the red channel has to read as red in both themes. Two sets, because // a single hue can't stay legible on both a paper and an ink ground. Green and // blue track DesignSystem.palette.series; the reds match its alert hue. const CHANNEL_COLORS = { light: { r: '#d32f2f', g: '#388e3c', b: '#1565c0' }, dark: { r: '#ef5350', g: '#66bb6a', b: '#4599e8' } }; const MODES = [ { value: 'rgb', label: 'RGB' }, { value: 'r', label: 'R' }, { value: 'g', label: 'G' }, { value: 'b', label: 'B' }, { value: 'l', label: 'L' } ]; const AXIS_H = 13; // strip under the plot: end labels, and handle grips const MAX_PLOT = 240; // the grid is square, but not unboundedly tall const MIN_PLOT = 110; const HIT_PX = 10; // pointer slop for grabbing a control point const ESCAPE_PX = 26; // drag this far outside the plot to delete a point const MAX_POINTS = 16; const clamp = (v, lo, hi) => (v < lo ? lo : (v > hi ? hi : v)); // ---- curve point maths --------------------------------------------------- // Points are integer [input, output] pairs in ascending input order; the two // endpoints keep their x pinned so the curve always spans the full range. const movePoint = (points, index, x, y) => { const last = points.length - 1; let nx; if (index === 0) nx = 0; else if (index === last) nx = 255; else nx = clamp(x, points[index - 1][0] + 1, points[index + 1][0] - 1); const next = points.slice(); next[index] = [Math.round(nx), Math.round(clamp(y, 0, 255))]; return next; }; const insertPoint = (points, x, y) => { const nx = clamp(Math.round(x), 1, 254); let at = 0; while (at < points.length && points[at][0] < nx) at++; // Refuse to stack a point on an existing input value — the spline needs // strictly increasing x. if (points.some((p) => p[0] === nx)) return { points: points, index: -1 }; const next = points.slice(); next.splice(at, 0, [nx, Math.round(clamp(y, 0, 255))]); return { points: next, index: at }; }; const removePoint = (points, index) => { if (index <= 0 || index >= points.length - 1) return points; const next = points.slice(); next.splice(index, 1); return next; }; // ---- panel --------------------------------------------------------------- const GraphPanel = ({ T, DS, Ops, tool, params, histogramCanvas, histogramKey, mode, onMode, onParam }) => { const wrapRef = React.useRef(null); const canvasRef = React.useRef(null); const dragRef = React.useRef(null); const tokensRef = React.useRef(T); tokensRef.current = T; const [width, setWidth] = React.useState(0); const [histogram, setHistogram] = React.useState(null); const [selected, setSelected] = React.useState(-1); const graph = tool.graph || {}; // Which side of the op the page handed us. A tool that draws a transfer // curve plots its input (curve and histogram then share an x axis); one // without a curve plots its output, or nothing on the panel would respond // to its controls. const showsOutput = graph.histogram === 'output'; // The schema entry holding the curve for the current channel, when the tool // has editable curves. Luma has none, so it falls back to reading the // composite curve — visible, but not draggable. const curveEntry = React.useMemo(() => { if (graph.curve !== 'edit') return null; const wanted = mode === 'l' ? 'rgb' : mode; return tool.paramSchema.find((s) => s.type === 'curve' && s.channel === wanted) || null; }, [tool, mode, graph.curve]); const editable = !!curveEntry && mode !== 'l'; const points = curveEntry ? params[curveEntry.key] : null; // ---- geometry ---------------------------------------------------------- const plot = React.useMemo(() => { const w = Math.max(1, width - 1); return { x: 0.5, y: 0.5, w: w, h: clamp(w, MIN_PLOT, MAX_PLOT) }; }, [width]); const cssHeight = plot.h + 1 + AXIS_H; const toX = (v) => plot.x + (v / 255) * plot.w; const toY = (v) => plot.y + plot.h - (v / 255) * plot.h; const fromX = (px) => clamp(Math.round(((px - plot.x) / plot.w) * 255), 0, 255); const fromY = (py) => clamp(Math.round(((plot.y + plot.h - py) / plot.h) * 255), 0, 255); React.useLayoutEffect(() => { const el = wrapRef.current; if (!el) return undefined; const measure = () => setWidth(el.clientWidth); measure(); if (typeof ResizeObserver === 'undefined') return undefined; const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); // ---- histogram --------------------------------------------------------- // The only expensive work in this component, and for a curve tool it is // deliberately keyed on the *input* canvas: dragging that tool's slider or // curve doesn't change what feeds it, so a scrub repaints the curve without // rescanning a pixel. An output histogram has to be rescanned per frame — // that is the whole point of it — but histogramOf() stride-samples, so a // scrub stays a fixed ~200k reads however big the image is. React.useEffect(() => { if (!histogramCanvas || !graph.histogram) { setHistogram(null); return; } setHistogram(Ops.histogramOf(histogramCanvas)); }, [histogramCanvas, histogramKey, graph.histogram]); // ---- the transfer curve(s) to draw -------------------------------------- // One entry per curve: { table, color }. A lut tool that returns per-channel // tables shows all three at once in the composite views. const curves = React.useMemo(() => { const tint = CHANNEL_COLORS[T.darkMode ? 'dark' : 'light']; if (curveEntry) { return [{ table: Ops.curveLut(params[curveEntry.key]), color: mode === 'rgb' || mode === 'l' ? T.accent : tint[mode] }]; } if (graph.curve !== 'lut' || !tool.lut) return []; const out = tool.lut(params); if (!out.r) return [{ table: out, color: T.accent }]; if (mode === 'r' || mode === 'g' || mode === 'b') { return [{ table: out[mode], color: tint[mode] }]; } return [{ table: out.r, color: tint.r }, { table: out.g, color: tint.g }, { table: out.b, color: tint.b }]; }, [tool, params, mode, curveEntry, T.accent, T.darkMode]); // ---- paint ------------------------------------------------------------- // Repaints on every param change; the histogram bins come from state, so a // repaint is a few hundred lineTo calls. React.useEffect(() => { const canvas = canvasRef.current; if (!canvas || width <= 0) return; const tok = tokensRef.current; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.round(width * dpr); canvas.height = Math.round(cssHeight * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, cssHeight); // 1. histogram --------------------------------------------------------- if (histogram) { const shown = mode === 'rgb' ? ['r', 'g', 'b'] : [mode]; // Normalise against the tallest interior bin: pure black or pure white // areas pile into bins 0 and 255 and would flatten everything else. let peak = 0; shown.forEach((ch) => { const bins = histogram[ch]; for (let v = 1; v < 255; v++) if (bins[v] > peak) peak = bins[v]; }); if (!peak) shown.forEach((ch) => { peak = Math.max(peak, histogram.max[ch]); }); const fill = (bins, color, alpha) => { ctx.beginPath(); ctx.moveTo(plot.x, plot.y + plot.h); for (let v = 0; v < 256; v++) { ctx.lineTo(toX(v), plot.y + plot.h - Math.min(1, bins[v] / peak) * plot.h); } ctx.lineTo(plot.x + plot.w, plot.y + plot.h); ctx.closePath(); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fill(); ctx.globalAlpha = 1; }; if (peak) { const tint = CHANNEL_COLORS[tok.darkMode ? 'dark' : 'light']; if (mode === 'rgb') { // Overlap has to darken on the paper ground and lighten on the ink // one, or the region where all three channels agree disappears into // the background. ctx.save(); ctx.globalCompositeOperation = tok.darkMode ? 'lighter' : 'multiply'; fill(histogram.r, tint.r, tok.darkMode ? 0.5 : 0.8); fill(histogram.g, tint.g, tok.darkMode ? 0.5 : 0.8); fill(histogram.b, tint.b, tok.darkMode ? 0.5 : 0.8); ctx.restore(); } else { fill(histogram[mode], mode === 'l' ? tok.textBody : tint[mode], 0.45); } } } // 2. grid, identity diagonal, border ------------------------------------ ctx.lineWidth = 1; ctx.strokeStyle = tok.dividerFaint; ctx.beginPath(); for (let i = 1; i < 4; i++) { const gx = Math.round(plot.x + (plot.w * i) / 4) + 0.5; const gy = Math.round(plot.y + (plot.h * i) / 4) + 0.5; ctx.moveTo(gx, plot.y); ctx.lineTo(gx, plot.y + plot.h); ctx.moveTo(plot.x, gy); ctx.lineTo(plot.x + plot.w, gy); } ctx.stroke(); ctx.save(); ctx.setLineDash([2, 3]); ctx.strokeStyle = tok.dividerFaint; ctx.beginPath(); ctx.moveTo(plot.x, plot.y + plot.h); ctx.lineTo(plot.x + plot.w, plot.y); ctx.stroke(); ctx.restore(); ctx.strokeStyle = tok.divider; ctx.strokeRect(plot.x, plot.y, plot.w, plot.h); // 3. transfer curves ----------------------------------------------------- curves.forEach((entry) => { ctx.beginPath(); for (let v = 0; v < 256; v++) { const x = toX(v); const y = toY(entry.table[v]); if (v === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = entry.color; ctx.lineWidth = 1.5; ctx.stroke(); }); ctx.lineWidth = 1; // 4. control points ------------------------------------------------------ if (editable && points) { points.forEach((p, i) => { const x = Math.round(toX(p[0])); const y = Math.round(toY(p[1])); ctx.fillStyle = i === selected ? tok.textStrong : tok.card; ctx.strokeStyle = i === selected ? tok.textStrong : tok.accent; ctx.fillRect(x - 3, y - 3, 6, 6); ctx.strokeRect(x - 3.5, y - 3.5, 7, 7); }); } // 5. input-axis handles -------------------------------------------------- const axisY = plot.y + plot.h; (graph.handles || []).forEach((handle) => { const x = Math.round(toX(params[handle.param])) + 0.5; ctx.save(); ctx.setLineDash([3, 2]); ctx.strokeStyle = tok.accent; ctx.beginPath(); ctx.moveTo(x, plot.y); ctx.lineTo(x, axisY); ctx.stroke(); ctx.restore(); // The grip stays fully on-canvas even when its value sits at 0 or 255; // the dashed line still marks the true position. const gx = clamp(x, plot.x + 3.5, plot.x + plot.w - 3.5); ctx.fillStyle = tok.accent; ctx.fillRect(gx - 3.5, axisY + 2, 7, 7); }); // 6. axis end labels ----------------------------------------------------- ctx.fillStyle = tok.textMuted; ctx.font = '9.5px ' + DS.MONO; ctx.textBaseline = 'top'; ctx.textAlign = 'left'; ctx.fillText('0', plot.x, axisY + 3); ctx.textAlign = 'right'; ctx.fillText('255', plot.x + plot.w, axisY + 3); }, [histogram, curves, points, editable, selected, mode, width, cssHeight, plot, params, graph.handles, T.darkMode, DS]); // ---- pointer interaction ------------------------------------------------- const localPoint = (event) => { const rect = canvasRef.current.getBoundingClientRect(); return { px: event.clientX - rect.left, py: event.clientY - rect.top }; }; const nearestHandle = (px, py) => { const handles = graph.handles || []; if (!handles.length) return null; // Grabbable along the whole dashed line, and anywhere in the axis strip // beneath it — the grip square alone is a small target on a phone. const inStrip = py > plot.y + plot.h; let best = null; handles.forEach((handle) => { const dx = Math.abs(px - toX(params[handle.param])); if (dx <= (inStrip ? 24 : HIT_PX) && (!best || dx < best.dx)) { best = { handle: handle, dx: dx }; } }); if (!best && inStrip) return null; return best ? best.handle : null; }; const hitPoint = (px, py) => { if (!editable || !points) return -1; let found = -1; let bestDist = HIT_PX * HIT_PX; points.forEach((p, i) => { const dx = px - toX(p[0]); const dy = py - toY(p[1]); const dist = dx * dx + dy * dy; if (dist <= bestDist) { bestDist = dist; found = i; } }); return found; }; // Levels' two markers must stay ordered, and each still honours its own // schema bounds. const setHandle = (handle, value) => { const schema = tool.paramSchema.find((s) => s.key === handle.param); let next = clamp(value, schema ? schema.min : 0, schema ? schema.max : 255); const others = (graph.handles || []).filter((h) => h !== handle); others.forEach((other) => { const otherValue = params[other.param]; if (toX(otherValue) < toX(params[handle.param])) next = Math.max(next, otherValue + 1); else next = Math.min(next, otherValue - 1); }); onParam(handle.param, next); }; const onPointerDown = (event) => { if (event.button != null && event.button !== 0) return; // Without this a mouse drag across the canvas also drags a text selection // through the labels around it. event.preventDefault(); const { px, py } = localPoint(event); const handle = nearestHandle(px, py); if (handle) { dragRef.current = { kind: 'handle', handle: handle }; event.currentTarget.setPointerCapture(event.pointerId); setHandle(handle, fromX(px)); return; } if (!editable || py > plot.y + plot.h) return; let index = hitPoint(px, py); if (index < 0) { if (points.length >= MAX_POINTS) return; const added = insertPoint(points, fromX(px), fromY(py)); if (added.index < 0) return; index = added.index; onParam(curveEntry.key, added.points); } dragRef.current = { kind: 'point', index: index }; setSelected(index); event.currentTarget.setPointerCapture(event.pointerId); }; const onPointerMove = (event) => { const drag = dragRef.current; if (!drag) return; const { px, py } = localPoint(event); if (drag.kind === 'handle') { setHandle(drag.handle, fromX(px)); return; } // Flicking a point out of the plot deletes it — the same gesture as // dragging a layer off a stack, and quicker than aiming a double-click. const outside = px < plot.x - ESCAPE_PX || px > plot.x + plot.w + ESCAPE_PX || py < plot.y - ESCAPE_PX || py > plot.y + plot.h + ESCAPE_PX; if (outside && drag.index > 0 && drag.index < points.length - 1) { onParam(curveEntry.key, removePoint(points, drag.index)); dragRef.current = null; setSelected(-1); return; } onParam(curveEntry.key, movePoint(points, drag.index, fromX(px), fromY(py))); }; const endDrag = () => { dragRef.current = null; }; const onDoubleClick = (event) => { if (!editable || !points) return; const { px, py } = localPoint(event); const index = hitPoint(px, py); if (index > 0 && index < points.length - 1) { onParam(curveEntry.key, removePoint(points, index)); setSelected(-1); } }; const resettable = editable && points && !Ops.isDefaultCurve(points); return ( {editable ? 'Curve' : 'Histogram'} {editable ? points.length + ' pts' : (showsOutput ? 'output' : 'input')} ({ value: m.value, label: m.label, active: mode === m.value, onClick: () => onMode(m.value) }))} /> {editable ? 'Click to add a point, drag to shape, double-click or drag out to remove.' : (showsOutput ? 'Distribution after this adjustment.' : 'Distribution entering this adjustment.')} {resettable ? ( ) : null} ); }; window.ImageEditorGraph = { GraphPanel: GraphPanel, MODES: MODES }; })();