web: the eyedropper reads a colour and the mixer moves that hue band
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user