From 27035c4acb85cde96199c1aac6cbf250f99a1995 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 18:27:28 +0700 Subject: [PATCH] web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo --- docker/frontend/src/App.tsx | 141 ++++++++++++++++----- docker/frontend/src/styles/app.css | 62 +++++++++ docker/frontend/src/ui/ChipColumn.tsx | 58 +++++++++ docker/frontend/src/ui/ImageStage.tsx | 102 ++++++++++++++- docker/frontend/src/ui/StraightenRuler.tsx | 79 ++++++++++++ 5 files changed, 401 insertions(+), 41 deletions(-) create mode 100644 docker/frontend/src/ui/StraightenRuler.tsx diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index bd8a5ef..bc7572e 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ToolRail, type TabId } from './ui/ToolRail'; -import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; +import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; import { ImageStage } from './ui/ImageStage'; +import { StraightenRuler } from './ui/StraightenRuler'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; import { NameModal } from './ui/NameModal'; @@ -195,6 +196,8 @@ export function Workspace() { const [hslBand, setHslBand] = useState('red'); const [picking, setPicking] = useState(false); const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null); + // Where on the photo that colour was read: the mixer's panel hangs there. + const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null); const [openGroup, setOpenGroup] = useState(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); @@ -592,6 +595,23 @@ export function Workspace() { [remember] ); + // One band of the mixer, read and written through one door: the tab's ruler + // and the panel hung on the photo are the same three knobs. 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. + const setBandKnob = useCallback( + (which: 0 | 1 | 2, v: number) => { + const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); + const next: HslBand = [band[0], band[1], band[2]]; + next[which] = Math.round(v); + const bands = { ...recipe.adjustments.hslBands }; + if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand]; + else bands[hslBand] = next; + setAdjustmentOnce({ hslBands: bands }); + }, + [recipe.adjustments.hslBands, hslBand, setAdjustmentOnce] + ); + // --- crop ----------------------------------------------------------------- // Picking a ratio only opens the frame; the crop is committed by APPLY. const pickCrop = useCallback( @@ -1077,10 +1097,28 @@ export function Workspace() { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); - // Leaving the mixer disarms the eyedropper: it is a mode of that tab. + // Leaving the mixer disarms the eyedropper and takes its panel off the + // photo: both are modes of that tab. setPicking(false); + setPickedAt(null); }, [tab]); + // STRAIGHTEN's scale is a mode of the photo, like the eyedropper: any click + // that lands outside it applies the angle and takes the scale off the photo — + // so reaching for another tab, chip or button levels the photo and puts the + // ruler away in the same gesture. Nothing is pending: the angle is already + // live in the render, this only ends the session. + useEffect(() => { + if (openParam !== 'straighten') return; + const dismiss = (e: PointerEvent) => { + const el = e.target as HTMLElement | null; + if (el?.closest('[data-key="straighten-ruler"], .chip[data-key="straighten"]')) return; + setOpenParam(null); + }; + window.addEventListener('pointerdown', dismiss, true); + return () => window.removeEventListener('pointerdown', dismiss, true); + }, [openParam]); + // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => @@ -1100,42 +1138,18 @@ export function Workspace() { const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; - // STRAIGHTEN is not a knob either: it is FRAME's own angle, and its ruler - // spans the full circle so a photo can be levelled either way round. - if (openParam === 'straighten') { - return { - key: 'straighten', - label: 'STRAIGHTEN', - value: straighten, - min: -180, - max: 180, - step: 1, - display: `${straighten > 0 ? '+' : ''}${straighten}°`, - edited: straighten !== 0, - onChange: setStraightenOnce, - onReset: () => setStraightenOnce(0), - onBack: () => { - setOpenParam(null); - setOpenGroup('rotate'); - }, - }; - } + // STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not + // stacked in this column at all. Its ruler lies across the foot of the + // photo (StraightenRuler, drawn by ImageStage) so the angle is read off the + // picture it is levelling; nothing opens here. + if (openParam === 'straighten') 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 set = (v: number) => setBandKnob(which as 0 | 1 | 2, v); const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM'; return { key: openParam, @@ -1175,16 +1189,64 @@ export function Workspace() { }, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand, straighten, setStraightenOnce]); + }, [openParam, recipe.adjustments, setAdjustmentOnce, setBandKnob, hslBand, straighten, setStraightenOnce]); // 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 }) => { + // colour's band, hang the mixer's panel on the spot it came from, and put the + // tool away — one pick is one colour. + const pickColor = useCallback((rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => { setSample(rgb); setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h)); + setPickedAt(at); setPicking(false); }, []); const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null; + const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''; + // The colour as this band's knobs leave it: the panel's swatch, and what the + // mixer is doing to every pixel the band owns. + const mixed = (() => { + if (!picked) return null; + const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); + const h = picked.h + band[0]; + const s = Math.max(0, Math.min(100, picked.s * 100 + band[1])); + const l = Math.max(0, Math.min(100, picked.l * 100 + band[2])); + return { hex: hslToHex(h, s, l) }; + })(); + const bandKnobs = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); + // The panel the stage hangs on the picked point: the colour on top (it moves + // with the knobs), then HUE/SAT/LUM in one row under it. Each takes the wheel + // for one step and a double-click back to 0. + const pickPanel = + sample && picked && mixed && pickedAt ? ( + <> +
+ + + {bandName} + {mixed.hex} + + +
+
+ setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} /> + setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} /> + setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} /> +
+ + ) : null; // Option strips: a chip opens one, each option picks a value. `off` is the // neutral value the chip glows amber against. @@ -1543,6 +1605,8 @@ export function Workspace() { return { key: `hsl-band-${b.id}`, label: b.label, + // The chip names a colour, so it shows the colour it names. + color: hslToHex(b.hue, 70, 50), active: hslBand === b.id, amberValue: hslBand !== b.id && moved, onClick: () => setHslBand(b.id), @@ -1900,6 +1964,13 @@ export function Workspace() { canCompare={crop === 'none'} picking={picking} onPickColor={pickColor} + pickPanel={pickPanel} + pickPanelAt={pickedAt} + straightenRuler={ + openParam === 'straighten' ? ( + setStraightenOnce(0)} /> + ) : null + } /> diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index bac34b0..6287084 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -314,6 +314,68 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } } .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; } +/* A chip that names a colour shows it: the dot rides the leading edge. */ +.chip-dot { width: 10px; height: 10px; border-radius: 999px; border: 1px solid var(--border); flex: 0 0 auto; } +/* The eyedropper's own icon, riding the cursor inside the pick layer. Its hot + spot is the tip, so it is nudged up and left of the pointer. */ +.pick-icon { position: absolute; pointer-events: none; transform: translate(-2px, -21px); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65)); } +.pick-icon-body { fill: #fff; stroke: #111; stroke-width: 1.2; } +.pick-icon-tip { fill: var(--accent); stroke: #111; stroke-width: 1.2; } +/* The mixer's panel for the colour just sampled: it hangs on the photo at the + point the eyedropper read, its swatch on top and HUE/SAT/LUM in one row + under it. The layer is transparent to the pointer, the card is not. */ +.hsl-panel-layer { position: absolute; pointer-events: none; } +.hsl-panel { + position: absolute; + pointer-events: auto; + width: 248px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} +.hsl-card-head { display: flex; align-items: center; gap: 8px; } +.hsl-card-swatch { width: 28px; height: 28px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.hsl-card-name { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; font-size: 11px; letter-spacing: 0.08em; color: var(--text-dim); } +.hsl-card-name b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); } +.hsl-card-close { border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 999px; padding: 2px 7px; font-size: 11px; cursor: pointer; } +.hsl-card-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.mini-slider { min-width: 0; } +.mini-head { display: flex; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } +.mini-head b { font-family: var(--mono); letter-spacing: 0; color: var(--text); } +.mini-head b.amber { color: var(--accent); } +/* FRAME's STRAIGHTEN scale: a graduated ruler lying along the foot of the + photo. The layer is transparent to the pointer so the photo still answers a + drag outside the card; the card itself takes the drag. */ +.straighten-layer { position: absolute; z-index: 3; pointer-events: none; } +.straighten-ruler { + position: absolute; + left: 10px; + right: 10px; + bottom: 10px; + padding: 6px 14px 8px; + pointer-events: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-elev) 90%, transparent); + backdrop-filter: blur(6px); + box-shadow: var(--shadow); + user-select: none; + touch-action: none; +} +.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } +.straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; } +.straighten-head b.amber { color: var(--accent); } +.straighten-scale { position: relative; height: 30px; cursor: ew-resize; } +.straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); } +.straighten-tick.major { height: 12px; background: var(--text-dim); } +.straighten-tick.zero { height: 16px; background: var(--text); } +.straighten-tick i { position: absolute; top: 13px; left: 0; transform: translateX(-50%); font-style: normal; font-size: 9px; color: var(--text-dim); } +.straighten-needle { position: absolute; bottom: 4px; width: 2px; height: 20px; background: var(--accent); transform: translateX(-50%); pointer-events: none; } /* 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. */ diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index d1bd367..17d655d 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -14,6 +14,9 @@ export interface ChipDef { amberValue?: boolean; pro?: boolean; disabled?: boolean; + // CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band + // chips name a colour, so they show it too. + color?: string; // Present when the chip itself can be dragged somewhere: the payload the drop // target reads back (the recipe's id, for the FAVORITED rail button). drag?: string; @@ -62,6 +65,7 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) { } onClick={chip.onClick} > + {chip.color ? : null} {chip.label} {chip.value ? {chip.value} : null} {chip.pro ? PRO : null} @@ -123,3 +127,57 @@ export function SliderRow({ slider }: { slider: SliderDef }) { ); } + +// The same ruler without its own column: one knob of the mixer's inline panel, +// where HUE/SAT/LUM sit side by side under the colour they are moving. Wheel +// steps it, double-click puts it back to 0. +export function MiniSlider({ + label, + value, + onChange, + onReset, +}: { + label: string; + value: number; + onChange: (value: number) => void; + onReset: () => void; +}) { + const ref = useRef(null); + const live = useRef({ value, onChange }); + live.current = { value, onChange }; + useEffect(() => { + const el = ref.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + // The panel lives on the stage, whose own wheel zooms the photo: the + // knob takes the notch and keeps it. + e.preventDefault(); + e.stopPropagation(); + const s = live.current; + const next = Math.max(-10, Math.min(10, s.value + (e.deltaY < 0 ? 1 : -1))); + if (next !== s.value) s.onChange(next); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); + return ( +
+
+ {label} + + {value > 0 ? `+${value}` : String(value)} + +
+ onChange(Number(e.target.value))} + onDoubleClick={onReset} + /> +
+ ); +} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 58bc620..1e3d918 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; import { samplePixel } from '../engine/imageOps'; @@ -29,6 +29,9 @@ export function ImageStage({ canCompare = false, picking = false, onPickColor, + pickPanel = null, + pickPanelAt = null, + straightenRuler = null, }: { previewUrl: string | null; originalUrl: string | null; @@ -53,8 +56,17 @@ export function ImageStage({ // 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; + onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void; + // The mixer's panel for the colour just sampled: hung on the photo at the + // point it was read from, so the swatch and its three knobs sit on the colour + // they are moving. + pickPanel?: ReactNode; + pickPanelAt?: { fx: number; fy: number } | null; + // FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here + // rather than in the panel column because the angle is read off the picture. + straightenRuler?: ReactNode; }) { + const showStraighten = !!straightenRuler; const { t } = useI18n(); const inputRef = useRef(null); const wrapRef = useRef(null); @@ -78,6 +90,9 @@ export function ImageStage({ // 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); + // Where the eyedropper's own icon is drawing itself inside the photo: the + // layer is the photo's box, so plain pixels are enough. + const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null); // \ toggles PEEK; the key is the app's own shortcut, kept identical on web. useEffect(() => { @@ -121,14 +136,14 @@ export function ImageStage({ }, []); useEffect(() => { - if (!cropEditing && !compare && !picking) { + if (!cropEditing && !compare && !picking && !pickPanelAt && !showStraighten) { setBox(null); return; } measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); - }, [cropEditing, compare, picking, measure, previewUrl]); + }, [cropEditing, compare, picking, pickPanelAt, showStraighten, measure, previewUrl]); // Compare is a property of the un-cropped photo, so a crop taking over (or a // new photo arriving) puts it away. @@ -167,7 +182,7 @@ export function ImageStage({ pickCache.current?.bmp.close(); pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) }; } - onPickColor(samplePixel(pickCache.current.bmp, fx, fy)); + onPickColor(samplePixel(pickCache.current.bmp, fx, fy), { fx, fy }); }, [previewUrl, onPickColor] ); @@ -480,8 +495,83 @@ export function ImageStage({ view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`, }} onPointerDown={(e) => e.stopPropagation()} + onPointerMove={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + setPickPos({ x: e.clientX - r.left, y: e.clientY - r.top }); + }} + onPointerLeave={() => setPickPos(null)} onClick={pickAt} - /> + > + {/* The picker's own icon rides the cursor, so the tool is visible + on the photo instead of only in the chip row. */} + + + + + ) : null} + {/* The mixer's panel for the colour the eyedropper just read: it hangs + on the photo at the point it was read from, and its swatch follows + the three knobs under it. */} + {pickPanel && pickPanelAt && box ? ( +
+
e.stopPropagation()} + style={{ + left: `${pickPanelAt.fx * 100}%`, + top: `${pickPanelAt.fy * 100}%`, + // Below the point, or above it when the point sits low enough + // that the panel would fall off the photo. + transform: pickPanelAt.fy > 0.55 ? 'translate(-50%, calc(-100% - 14px))' : 'translate(-50%, 14px)', + }} + > + {pickPanel} +
+
+ ) : null} + {/* FRAME's STRAIGHTEN scale: a graduated ruler along the foot of the + photo, so the angle is turned while the picture that answers it is + in front of the eye. */} + {straightenRuler && box && !cropEditing ? ( +
+ {straightenRuler} +
) : null} {compare && canCompare && originalUrl && box ? (
ROTATE > STRAIGHTEN's ruler is not one of the chip row's stacked +// knobs: it is a graduated scale lying across the foot of the photo, so the +// angle is read off the picture it is levelling. Dragging anywhere on the scale +// turns the photo by whole degrees; leaving the tool (another tab or button) +// keeps the angle and takes the scale away — see App's dismiss effect. +const RANGE = 180; // degrees either side of 0, the whole circle across the width +const TICK = 5; // minor tick spacing +const MAJOR = 45; // labelled tick spacing + +export function StraightenRuler({ + value, + onChange, + onReset, +}: { + value: number; + onChange: (v: number) => void; + onReset: () => void; +}) { + const scaleRef = useRef(null); + const dragging = useRef(false); + + // A pixel of travel is well under a degree at any sane width, so the reading + // snaps to whole degrees however far the pointer has been dragged. + const angleAt = (clientX: number) => { + const r = scaleRef.current?.getBoundingClientRect(); + if (!r || !(r.width > 0)) return value; + const pct = (clientX - r.left) / r.width; + return Math.max(-RANGE, Math.min(RANGE, Math.round(pct * 2 * RANGE - RANGE))); + }; + + const ticks: number[] = []; + for (let d = -RANGE; d <= RANGE; d += TICK) ticks.push(d); + const pctOf = (d: number) => ((d + RANGE) / (2 * RANGE)) * 100; + + return ( +
e.stopPropagation()}> +
+ STRAIGHTEN + + {value > 0 ? `+${value}` : value}° + +
+
{ + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + onChange(angleAt(e.clientX)); + }} + onPointerMove={(e) => { + if (dragging.current) onChange(angleAt(e.clientX)); + }} + onPointerUp={(e) => { + dragging.current = false; + e.currentTarget.releasePointerCapture(e.pointerId); + }} + onPointerCancel={() => { + dragging.current = false; + }} + onDoubleClick={onReset} + > + {ticks.map((d) => ( + + {d % MAJOR === 0 ? {d > 0 ? `+${d}` : d} : null} + + ))} + +
+
+ ); +}