web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user