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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user