web: the mixer's card can be dragged off the colour it was read at

The card hangs on the point the eyedropper read, which is exactly where the
user wants to watch the band move — so it covers the patch it is editing. Its
body now takes a drag: the offset is a fraction of the photo, which is the
layer the card lives in, so a zoom keeps it where it was put and a fresh pick
drops it back on its own point. The knobs and the close button keep the pointer
to themselves, and the anchor cannot leave the photo, so the card is always
half in reach of a drag back.
This commit is contained in:
2026-09-22 11:13:21 +07:00
parent 88afdf6611
commit 7dea0f1e7b
2 changed files with 46 additions and 3 deletions
+4
View File
@@ -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;
+42 -3
View File
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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({
<div
className="hsl-panel"
data-key="hsl-panel"
onPointerDown={(e) => 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)',