diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index b4018d7..c48e1f1 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 1268175..22a3c33 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 42635fe..4690fa1 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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; diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx new file mode 100644 index 0000000..91f8260 --- /dev/null +++ b/docker/frontend/src/ui/Histogram.tsx @@ -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 { + 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(BINS).fill(0); + const r = new Array(BINS).fill(0); + const g = new Array(BINS).fill(0); + const b = new Array(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(null); + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState(false); + const boxRef = useRef(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) => { + 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) => { + 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) => { + 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 ( +
+
+ {t('hist.title')} + +
+ {data ? ( + + + + + + + ) : ( +
+ )} +
+ 0 + 255 +
+
+ ); +} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index fe55df4..d27c812 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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({
) : null} {peek ?
{t('drop.peeking')}
: 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 ? setShowHist(false)} /> : null} )} @@ -510,6 +518,15 @@ export function ImageStage({ > {t('drop.compare')} + {t('drop.peekHint')} ) : null}