web: overlay a draggable histogram on the photo

The panel in Lightroom is a running read of the render, so this one reads
the preview blob itself: one downscaled 320px canvas pass bins 256
values per channel, and four SVG paths draw them — the grey luma fill
with the three channel curves screened over it, from absolute black to
absolute white. Dragged by its header, clamped inside the photo, parked
top-right on first paint, and dismissed either from its own frame or from
the toolbar button. It steps aside while the crop frame is up.
This commit is contained in:
2026-09-18 14:35:50 +07:00
parent 8308b3e65f
commit 27099c2877
5 changed files with 243 additions and 0 deletions
+4
View File
@@ -27,6 +27,10 @@ export const en: Dict = {
'drop.compare': 'Compare with original picture',
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
'hist.title': 'HISTOGRAM',
'hist.drag': 'Drag to move',
'hist.hide': 'Hide the histogram',
'tab.presets': 'PRESETS',
'tab.favorited': 'FAVORITED',
'tab.photos': 'MY PHOTOS',
+5
View File
@@ -34,6 +34,11 @@ export const vi = {
'drop.compare': 'So với ảnh gốc',
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
// The histogram overlay. 'HISTOGRAM' is the word Vietnamese photographers use.
'hist.title': 'HISTOGRAM',
'hist.drag': 'Kéo để di chuyển',
'hist.hide': 'Ẩn histogram',
'tab.presets': 'PRESETS',
'tab.favorited': 'FAVORITED',
'tab.photos': 'ẢNH CỦA TÔI',
+35
View File
@@ -277,6 +277,41 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
.crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; }
.crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
/* Histogram overlay: a small translucent frame on the photo, dragged by its
header. The luma curve is a grey fill behind the three channel curves, which
screen over each other the way a Lightroom histogram reads. */
.hist {
position: absolute;
z-index: 4;
width: 236px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg) 86%, transparent);
backdrop-filter: blur(6px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
user-select: none;
touch-action: none;
}
.hist-head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 8px; cursor: grab; }
.hist.dragging .hist-head { cursor: grabbing; }
.hist-title { flex: 1 1 auto; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
.hist-close {
width: 18px; height: 18px; padding: 0; line-height: 1;
font-size: 13px; color: var(--text-dim);
background: none; border: 0; border-radius: 4px; cursor: pointer;
}
.hist-close:hover { background: var(--bg-sunken); color: var(--text); }
.hist-plot { display: block; width: 100%; height: 84px; }
.hist-lum { fill: var(--text-dim); opacity: 0.38; }
.hist-ch { fill: none; stroke-width: 1.1; mix-blend-mode: screen; }
.hist-r { stroke: #ff5a5a; }
.hist-g { stroke: #4ade80; }
.hist-b { stroke: #5aa2ff; }
.hist-axis {
display: flex; justify-content: space-between;
padding: 1px 6px 4px;
font-family: var(--mono); font-size: 9px; color: var(--text-dim);
}
.busy {
position: absolute;
inset: auto 0 12px 0;
+182
View File
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
// The histogram overlay: a draggable frame over the photo, reading the render
// the user is actually looking at — so every knob shows up in it, exactly like
// the panel in Lightroom. No dependency: one downscaled canvas pass to bin the
// pixels, then four SVG paths.
const BINS = 256; // the whole 0..255 ramp, absolute black to absolute white
// The sample is downscaled before it is binned: a histogram is a distribution,
// not a measurement, and 320px of the render tells the same story as 3200.
const SAMPLE = 320;
const W = 256;
const H = 96;
export interface Histogram {
lum: number[];
r: number[];
g: number[];
b: number[];
}
// Bins `url` (a blob URL of the rendered preview) into 256 buckets per channel.
// `alive()` is checked after the await so a stale render cannot overwrite a
// newer one.
export async function readHistogram(url: string, alive: () => boolean): Promise<Histogram | null> {
const res = await fetch(url);
const bitmap = await createImageBitmap(await res.blob(), { resizeWidth: SAMPLE, resizeQuality: 'low' });
if (!alive()) {
bitmap.close();
return null;
}
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
const lum = new Array<number>(BINS).fill(0);
const r = new Array<number>(BINS).fill(0);
const g = new Array<number>(BINS).fill(0);
const b = new Array<number>(BINS).fill(0);
for (let i = 0; i < data.length; i += 4) {
r[data[i]]++;
g[data[i + 1]]++;
b[data[i + 2]]++;
// The shaders' own luma weights, so the grey curve matches what they see.
lum[(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2] + 0.5) | 0]++;
}
return { lum, r, g, b };
}
// One channel across the full width of the ramp. `close` also draws the floor,
// which is only wanted for the filled luminance curve.
function curve(bins: number[], max: number, close: boolean): string {
const step = W / (BINS - 1);
let d = '';
for (let i = 0; i < BINS; i++) {
d += `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - (bins[i] / max) * H).toFixed(1)} `;
}
return close ? `${d}L${W} ${H} L0 ${H} Z` : d;
}
export function Histogram({ url, onClose }: { url: string; onClose: () => void }) {
const { t } = useI18n();
const [data, setData] = useState<Histogram | null>(null);
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [dragging, setDragging] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ px: number; py: number; x: number; y: number } | null>(null);
// Re-read on every repaint of the render.
useEffect(() => {
let live = true;
readHistogram(url, () => live)
.then((h) => {
if (live && h) setData(h);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [url]);
// The frame lives inside the photo's own box, so the drag maths is in that
// box's coordinates and needs no reference to the stage.
const clamp = useCallback((x: number, y: number) => {
const wrap = boxRef.current?.parentElement;
const box = boxRef.current;
if (!wrap || !box) return { x, y };
const b = wrap.getBoundingClientRect();
const m = box.getBoundingClientRect();
const hi = (outer: number, inner: number) => Math.max(8, outer - inner - 8);
return { x: Math.min(Math.max(8, x), hi(b.width, m.width)), y: Math.min(Math.max(8, y), hi(b.height, m.height)) };
}, []);
// First paint: park it in the top-right corner of the photo.
useEffect(() => {
if (pos) return;
const wrap = boxRef.current?.parentElement;
const box = boxRef.current;
if (!wrap || !box) return;
const b = wrap.getBoundingClientRect();
setPos({ x: Math.max(8, b.width - box.getBoundingClientRect().width - 12), y: 12 });
}, [pos]);
useEffect(() => {
const onResize = () => setPos((p) => (p ? clamp(p.x, p.y) : p));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clamp]);
const start = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { px: e.clientX, py: e.clientY, x: pos?.x ?? 0, y: pos?.y ?? 0 };
setDragging(true);
};
const move = (e: React.PointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d) return;
e.preventDefault();
setPos(clamp(d.x + (e.clientX - d.px), d.y + (e.clientY - d.py)));
};
const end = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setDragging(false);
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
// One scale for all four curves, so their heights are comparable. ponytail:
// plain linear — a big sky flattens the rest; switch to a log scale here when
// that starts to hide the shadows.
const max = Math.max(1, ...(data ? [...data.lum, ...data.r, ...data.g, ...data.b] : [1]));
return (
<div
className={`hist${dragging ? ' dragging' : ''}`}
data-key="histogram"
ref={boxRef}
style={{ left: pos?.x ?? 12, top: pos?.y ?? 12, visibility: pos ? 'visible' : 'hidden' }}
>
<div
className="hist-head"
data-key="histogram-drag"
title={t('hist.drag')}
onPointerDown={start}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
>
<span className="hist-title">{t('hist.title')}</span>
<button type="button" className="hist-close" data-key="histogram-close" aria-label={t('hist.hide')} onClick={onClose}>
×
</button>
</div>
{data ? (
<svg className="hist-plot" data-key="histogram-plot" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img">
<path className="hist-lum" data-key="histogram-lum" d={curve(data.lum, max, true)} />
<path className="hist-ch hist-r" data-key="histogram-r" d={curve(data.r, max, false)} />
<path className="hist-ch hist-g" data-key="histogram-g" d={curve(data.g, max, false)} />
<path className="hist-ch hist-b" data-key="histogram-b" d={curve(data.b, max, false)} />
</svg>
) : (
<div className="hist-plot" data-key="histogram-plot" />
)}
<div className="hist-axis">
<span>0</span>
<span>255</span>
</div>
</div>
);
}
+17
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
import { Histogram } from './Histogram';
// One wheel notch, and how far the photo may be pulled open.
const ZOOM_STEP = 1.15;
@@ -67,6 +68,9 @@ export function ImageStage({
const [compare, setCompare] = useState(false);
const [split, setSplit] = useState(0.5);
const draggingSplit = useRef(false);
// The histogram overlay is shown by default and dismissed from its own frame
// or from the toolbar button; it steps aside while the crop frame is up.
const [showHist, setShowHist] = useState(true);
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
useEffect(() => {
@@ -489,6 +493,10 @@ export function ImageStage({
</div>
) : null}
{peek ? <div className="busy">{t('drop.peeking')}</div> : null}
{/* The histogram reads the render, so it goes over the photo itself.
Not while the crop frame is up: the frame's shade and handles own
that surface. */}
{showHist && !cropEditing ? <Histogram url={previewUrl} onClose={() => setShowHist(false)} /> : null}
</div>
)}
@@ -510,6 +518,15 @@ export function ImageStage({
>
{t('drop.compare')}
</button>
<button
type="button"
className={showHist ? 'btn primary' : 'btn'}
data-key="hist-toggle"
aria-pressed={showHist}
onClick={() => setShowHist((v) => !v)}
>
{t('hist.title')}
</button>
<span className="hint">{t('drop.peekHint')}</span>
</div>
) : null}