web: the eyedropper reads a colour and the mixer moves that hue band

This commit is contained in:
2026-09-18 17:41:45 +07:00
parent 8a889db069
commit 1b71c0196f
11 changed files with 449 additions and 7 deletions
+111 -1
View File
@@ -25,8 +25,11 @@ import {
type CropRect,
type FrameId,
type GPSInfo,
type HslBand,
type HslBandId,
type Recipe,
} from '../shared/types';
import { HSL_BANDS, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -183,6 +186,11 @@ export function Workspace() {
const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null);
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
const [openParam, setOpenParam] = useState<string | null>(null);
// The HSL mixer: which hue band the ruler edits, whether the eyedropper is
// armed, and the last colour it read off the photo.
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
@@ -1047,6 +1055,8 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
// Leaving the mixer disarms the eyedropper: it is a mode of that tab.
setPicking(false);
}, [tab]);
// Continuous parameter chips: label + live value; amber text when edited,
@@ -1068,6 +1078,37 @@ export function Workspace() {
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
// The mixer's three knobs are not in PARAM_DEFS: they read and write one
// band of hslBands rather than a flat field of adjustments.
if (openParam.startsWith('hsl.')) {
const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const value = band[which];
const set = (v: number) => {
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...recipe.adjustments.hslBands };
// An all-zero band is dropped, so an untouched band never lands in the
// recipe and a band put back to zero leaves no trace.
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
setAdjustmentOnce({ hslBands: bands });
};
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
return {
key: openParam,
label: `${name} ${HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}`,
value,
min: -10,
max: 10,
step: 1,
display: value > 0 ? `+${value}` : String(value),
edited: value !== 0,
onChange: set,
onReset: () => set(0),
onBack: () => setOpenParam(null),
};
}
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
const def = all.find((d) => d.key === openParam);
if (!def) return null;
@@ -1092,7 +1133,16 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, and put the tool away — one pick is one colour.
const pickColor = useCallback((rgb: { r: number; g: number; b: number }) => {
setSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setPicking(false);
}, []);
const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null;
// Option strips: a chip opens one, each option picks a value. `off` is the
// neutral value the chip glows amber against.
@@ -1276,6 +1326,7 @@ export function Workspace() {
const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[])
.some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]);
return tweaked
|| Object.keys(a.hslBands ?? {}).length > 0
|| (simId ?? 'sim-provia') !== 'sim-provia'
|| frameId !== 'none' || crop !== 'none' || rotation !== 0
|| markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1
@@ -1425,6 +1476,36 @@ export function Workspace() {
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
...paramChips(PARAM_DEFS.filters),
];
case 'hsl': {
// PICK arms the eyedropper; the eight band chips choose which band the
// ruler edits, and the ruler itself is opened by the three knobs below.
const cur = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const knob = (key: string, label: string, i: number): ChipDef => ({
key,
label,
value: cur[i] > 0 ? `+${cur[i]}` : String(cur[i]),
active: openParam === key,
amberValue: openParam !== key && cur[i] !== 0,
onClick: () => toggleParam(key),
});
return [
{ key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => setPicking((v) => !v) },
...HSL_BANDS.map((b): ChipDef => {
const band = recipe.adjustments.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `hsl-band-${b.id}`,
label: b.label,
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onClick: () => setHslBand(b.id),
};
}),
knob('hsl.h', 'HUE', 0),
knob('hsl.s', 'SAT', 1),
knob('hsl.l', 'LUM', 2),
];
}
case 'frame':
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
// column (see the wm column in the render), so the frame chips stay put.
@@ -1580,6 +1661,33 @@ export function Workspace() {
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* The mixer's readout: the colour the eyedropper last read, as the
RGB bytes and the HSL the band is indexed by. */}
{tab === 'hsl' ? (
<div className="hsl-readout" data-key="hsl-readout">
{sample && picked ? (
<>
<span
className="hsl-swatch"
data-key="hsl-swatch"
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
/>
<span className="hsl-line" data-key="hsl-rgb">
R {sample.r} · G {sample.g} · B {sample.b}
</span>
<span className="hsl-line" data-key="hsl-hsl">
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
</span>
<span className="hsl-line" data-key="hsl-band">
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
</span>
</>
) : (
<p className="hint">{t('hsl.hint')}</p>
)}
</div>
) : null}
{/* RESET is the way out of an edit, not one more chip: it sits under
a rule at the foot of the column so it reads as its own thing. */}
<div className="reset-row">
@@ -1730,6 +1838,8 @@ export function Workspace() {
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={crop === 'none'}
picking={picking}
onPickColor={pickColor}
/>
</div>
+19
View File
@@ -31,6 +31,25 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0
}
}
// The colour under one point of a photo, for the HSL mixer's eyedropper.
// `fx`/`fy` are fractions of the photo (0..1, from the click position over the
// <img>), so the caller needs no knowledge of the stage's zoom, pan or box.
// One pixel, read straight off the RENDER — the swatch then shows the colour
// the user is actually looking at, grade and all, which is what makes the
// band the eyedropper lands on the one they meant.
export function samplePixel(bitmap: ImageBitmap, fx: number, fy: number): { r: number; g: number; b: number } {
const sx = Math.min(bitmap.width - 1, Math.max(0, Math.floor(fx * bitmap.width)));
const sy = Math.min(bitmap.height - 1, Math.max(0, Math.floor(fy * bitmap.height)));
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return { r: 0, g: 0, b: 0 };
ctx.drawImage(bitmap, sx, sy, 1, 1, 0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
return { r, g, b };
}
// Uploads are capped by the API (and by nginx in front of it), so a phone's
// 12MP JPEG has to shrink before it goes up — the same downscale the preview
// uses. The ORIGINAL bytes come back untouched (same reference) when the photo
+2
View File
@@ -38,6 +38,8 @@ export const en: Dict = {
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.hsl': 'HSL',
'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO',
'tab.frame': 'FRAME',
'tab.save': 'SAVE RECENT',
'tab.create': 'CREATE RECIPES',
+2
View File
@@ -46,6 +46,8 @@ export const vi = {
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
'tab.hsl': 'HSL',
'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH',
'tab.frame': 'FRAME',
'tab.save': 'LƯU GẦN ĐÂY',
'tab.create': 'CREATE RECIPES',
+19
View File
@@ -295,6 +295,25 @@ 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; }
/* Eyedropper: the photo's own box, left transparent, with a crosshair cursor. */
.pick-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: crosshair;
touch-action: none;
}
/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the
ruler is pointed at. */
.hsl-readout {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.hsl-swatch { display: block; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.hsl-line { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* 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. */
+61 -2
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 { samplePixel } from '../engine/imageOps';
import { Histogram } from './Histogram';
// One wheel notch, and how far the photo may be pulled open.
@@ -26,6 +27,8 @@ export function ImageStage({
onCropRectChange,
onStageZoom,
canCompare = false,
picking = false,
onPickColor,
}: {
previewUrl: string | null;
originalUrl: string | null;
@@ -47,6 +50,10 @@ export function ImageStage({
// Compare needs the un-cropped photo on one side and the render on the other,
// so it is only offered while the crop is untouched.
canCompare?: boolean;
// Eyedropper mode: clicks on the photo report the colour underneath instead of
// panning it. The HSL mixer's PICK chip owns this flag.
picking?: boolean;
onPickColor?: (rgb: { r: number; g: number; b: number }) => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
@@ -114,14 +121,14 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare) {
if (!cropEditing && !compare && !picking) {
setBox(null);
return;
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cropEditing, compare, measure, previewUrl]);
}, [cropEditing, compare, picking, measure, previewUrl]);
// Compare is a property of the un-cropped photo, so a crop taking over (or a
// new photo arriving) puts it away.
@@ -132,6 +139,39 @@ export function ImageStage({
setCompare(false);
}, [originalUrl]);
// Eyedropper. The decode is done on the click, not when PICK is armed: a
// bitmap made up front would be stale after the first band move, and re-making
// it on every render would decode a fresh JPEG per slider step. One bitmap is
// kept for as long as the URL it came from is the one on the stage.
const pickCache = useRef<{ url: string; bmp: ImageBitmap } | null>(null);
useEffect(
() => () => {
pickCache.current?.bmp.close();
pickCache.current = null;
},
[]
);
const pickAt = useCallback(
async (e: React.MouseEvent) => {
const img = imgRef.current;
if (!img || !previewUrl || !onPickColor) return;
// The rect is the photo's own box however the stage has zoomed or panned
// it, so a click maps straight to a fraction of the render.
const rect = img.getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) return;
const fx = (e.clientX - rect.left) / rect.width;
const fy = (e.clientY - rect.top) / rect.height;
if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return;
if (!pickCache.current || pickCache.current.url !== previewUrl) {
const blob = await (await fetch(previewUrl)).blob();
pickCache.current?.bmp.close();
pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) };
}
onPickColor(samplePixel(pickCache.current.bmp, fx, fy));
},
[previewUrl, onPickColor]
);
// The img's box at scale 1 — the natural size fitted into the wrap, never
// enlarged. Computed rather than measured so the transform cannot feed back
// into it.
@@ -424,6 +464,25 @@ export function ImageStage({
}
}
/>
{/* Eyedropper. Sits over the photo so a click reads the colour under
the cursor; it swallows the pointer so the pan gesture never
starts. */}
{picking && !cropEditing && box ? (
<div
className="pick-layer"
data-key="pick-layer"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={pickAt}
/>
) : null}
{compare && canCompare && originalUrl && box ? (
<div
className="compare-layer"
+3 -1
View File
@@ -5,7 +5,7 @@ import { useI18n } from '../i18n/I18nProvider';
// eye corrects, and the web rail draws them in the order the panel opens. The
// rail is a single column on desktop and becomes the bottom bar under 860px
// (see styles/app.css).
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'save' | 'create';
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'hsl' | 'frame' | 'save' | 'create';
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
@@ -14,6 +14,8 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'light', label: 'tab.light', glyph: '☀' },
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
// The HSL mixer sits with the colour tabs: it edits one hue band at a time.
{ id: 'hsl', label: 'tab.hsl', glyph: '◍' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
// Keeping the look on the stage and building one from scratch are two acts,
// so they get two tabs: SAVE RECENT files what is open, CREATE RECIPES starts