diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 1661be2..64f1fba 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -357,6 +357,10 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .hsl-panel { position: absolute; pointer-events: auto; + /* Dragged by its body to a clear patch of photo; the knobs and the close + button keep the pointer to themselves. */ + cursor: grab; + touch-action: none; width: 248px; padding: 8px; display: flex; diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 6fff445..e896ea5 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -154,6 +154,16 @@ export function ImageStage({ // 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); + // The colour that was read is rarely a clear patch of photo, so the mixer's + // card can be dragged off the point it hangs on. The offset is a fraction of + // the photo — which is what the layer is — so a zoom keeps the card where it + // was put; a fresh pick starts it back on its point. + const [panelAt, setPanelAt] = useState({ x: 0, y: 0 }); + const panelDrag = useRef<{ id: number; px: number; py: number; ox: number; oy: number; w: number; h: number } | null>(null); + + useEffect(() => { + setPanelAt({ x: 0, y: 0 }); + }, [pickPanelAt]); // \ toggles PEEK; the key is the app's own shortcut, kept identical on web. useEffect(() => { @@ -206,6 +216,32 @@ export function ImageStage({ return () => window.removeEventListener('resize', measure); }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]); + const grabPanel = (e: React.PointerEvent) => { + e.stopPropagation(); // the photo must not pan under the card + if ((e.target as HTMLElement).closest('input, button, .mini-slider')) return; // those keep their own drag + const rect = e.currentTarget.parentElement?.getBoundingClientRect(); + if (!rect || !(rect.width > 0 && rect.height > 0)) return; + e.currentTarget.setPointerCapture(e.pointerId); + panelDrag.current = { id: e.pointerId, px: e.clientX, py: e.clientY, ox: panelAt.x, oy: panelAt.y, w: rect.width, h: rect.height }; + }; + + const movePanel = (e: React.PointerEvent) => { + const d = panelDrag.current; + if (!d || d.id !== e.pointerId || !pickPanelAt) return; + e.preventDefault(); + // The anchor stays on the photo, so half the card is always within reach of + // a drag back however far it has been pushed. + const fit = (v: number, at: number) => Math.max(-at, Math.min(1 - at, v)); + setPanelAt({ + x: fit(d.ox + (e.clientX - d.px) / d.w, pickPanelAt.fx), + y: fit(d.oy + (e.clientY - d.py) / d.h, pickPanelAt.fy), + }); + }; + + const endPanel = (e: React.PointerEvent) => { + if (panelDrag.current?.id === e.pointerId) panelDrag.current = null; + }; + // 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 @@ -684,10 +720,13 @@ export function ImageStage({
e.stopPropagation()} + onPointerDown={grabPanel} + onPointerMove={movePanel} + onPointerUp={endPanel} + onPointerCancel={endPanel} style={{ - left: `${pickPanelAt.fx * 100}%`, - top: `${pickPanelAt.fy * 100}%`, + left: `${(pickPanelAt.fx + panelAt.x) * 100}%`, + top: `${(pickPanelAt.fy + panelAt.y) * 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)',