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
+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