web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo

This commit is contained in:
2026-09-18 18:27:28 +07:00
parent 10466e122a
commit 27035c4acb
5 changed files with 401 additions and 41 deletions
+58
View File
@@ -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 ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
{chip.label}
{chip.value ? <span className="val">{chip.value}</span> : null}
{chip.pro ? <span className="pro">PRO</span> : null}
@@ -123,3 +127,57 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
</div>
);
}
// 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<HTMLDivElement>(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 (
<div className="mini-slider" ref={ref} data-key={`hsl-knob-${label}`}>
<div className="mini-head">
<span>{label}</span>
<b className={value !== 0 ? 'amber' : ''} data-key={`hsl-knob-${label}-value`}>
{value > 0 ? `+${value}` : String(value)}
</b>
</div>
<input
type="range"
min={-10}
max={10}
step={1}
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
onDoubleClick={onReset}
/>
</div>
);
}
+96 -6
View File
@@ -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<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(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. */}
<span
className="pick-icon"
data-key="pick-icon"
style={{
left: pickPos?.x ?? box.width / 2,
top: pickPos?.y ?? box.height / 2,
}}
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
className="pick-icon-body"
d="m14.5 5.5 4 4-8.4 8.4-3 1-1-1 1-3z"
/>
<path className="pick-icon-tip" d="m15.6 4.4 1.6-1.6a1.6 1.6 0 0 1 2.3 0l1.7 1.7a1.6 1.6 0 0 1 0 2.3l-1.6 1.6z" />
</svg>
</span>
</div>
) : 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 ? (
<div
className="hsl-panel-layer"
data-key="hsl-panel-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})`,
}}
>
<div
className="hsl-panel"
data-key="hsl-panel"
onPointerDown={(e) => 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}
</div>
</div>
) : 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 ? (
<div
className="straighten-layer"
data-key="straighten-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})`,
}}
>
{straightenRuler}
</div>
) : null}
{compare && canCompare && originalUrl && box ? (
<div
@@ -0,0 +1,79 @@
import { useRef } from 'react';
// FRAME > 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<HTMLDivElement>(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 (
<div className="straighten-ruler" data-key="straighten-ruler" onPointerDown={(e) => e.stopPropagation()}>
<div className="straighten-head">
<span className="straighten-label">STRAIGHTEN</span>
<b className={value !== 0 ? 'amber' : ''} data-key="straighten-value">
{value > 0 ? `+${value}` : value}°
</b>
</div>
<div
ref={scaleRef}
className="straighten-scale"
data-key="straighten-scale"
onPointerDown={(e) => {
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) => (
<span
key={d}
className={`straighten-tick${d === 0 ? ' zero' : d % MAJOR === 0 ? ' major' : ''}`}
style={{ left: `${pctOf(d)}%` }}
>
{d % MAJOR === 0 ? <i>{d > 0 ? `+${d}` : d}</i> : null}
</span>
))}
<span className="straighten-needle" data-key="straighten-needle" style={{ left: `${pctOf(value)}%` }} />
</div>
</div>
);
}