zoom is a pair of buttons, and the wheel belongs to the page

The wheel was the obvious gesture and it cannot be made to behave. To zoom without also scrolling the
panel it has to call preventDefault, which means the page stops scrolling whenever the pointer happens
to be over a picture — and a panel of slots is mostly picture. Not calling it means both happen at
once. There is no third option.

So `+` and `−` on hover, top right, grouped and left of Change and Remove: they are the pair somebody
presses repeatedly, and a destructive Remove sitting between them would be pressed by accident
eventually.

Anchored to the centre of the current window rather than to a pointer, because a button has no pointer
position — and that is what somebody expects from a `+` anyway: the middle of what I am looking at
stays where it is.

The level is shown between them. A press that changes nothing at 1.0x or 8x is then explained rather
than read as a dead button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 00:07:30 +01:00
co-authored by Claude Opus 5
parent 84336b8af5
commit 80a7d2683f
+72 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { ImageOff, Minus, Pencil, Plus, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaPicker } from './MediaPicker';
import { originalUrl, posterUrl } from './thumbnail';
@@ -46,12 +46,16 @@ import { originalUrl, posterUrl } from './thumbnail';
* Written in the picture's own coordinates rather than in pixels, because the slot is a different size
* on every screen and the crop has to mean the same thing on all of them.
*
* ── And the wheel zooms ──
* ── And a pair of buttons zoom ──
*
* Zooming in is the window getting smaller: `w` and `h` shrink together, keeping the slot's shape, so
* less of the picture fills the same box. Fit is the floor — you cannot show more of a photograph than
* there is — and it is anchored to the pointer, so the thing under the cursor stays under the cursor.
* Anchoring to the centre instead is fewer lines and feels wrong the moment you use it.
* there is.
*
* It was the wheel, which is the obvious gesture and cannot be made to behave. To zoom without also
* scrolling the panel it must call preventDefault, so the page stops scrolling whenever the pointer is
* over a picture — and a panel of slots is mostly picture. Not calling it means both happen at once.
* There is no third option, so the wheel belongs to the page and the zoom is `+` and `−`.
*/
/** `x,y,w,h` in percentages of the original. The site's format — see parseCrop in media/render.ts. */
@@ -172,52 +176,43 @@ export const MediaField = ({
};
/*
Wheel to zoom, on a NON-PASSIVE listener.
Zoom is a pair of buttons, not the wheel.
React attaches `onWheel` passively, and a passive listener may not call preventDefault — so the
zoom would work and scroll the panel at the same time. The only way to get a cancellable wheel
event is to add it by hand with `{ passive: false }`.
The wheel was the obvious gesture and it cannot be made to behave. To zoom without also scrolling
the panel it has to call preventDefault, which means the page stops scrolling whenever the pointer
happens to be over a picture — and a panel of three slots is mostly picture. Not calling it means
both happen at once. There is no third option, so the wheel is left to the page.
The handler is rebuilt whenever the crop changes, which is what keeps it reading the current window
rather than the one that existed when the picture loaded.
Anchored to the CENTRE of the current window, because a button has no pointer position to anchor
to. That is also the behaviour somebody expects from a `+`: the middle of what I am looking at
stays where it is.
*/
useEffect(() => {
const element = box.current;
if (!element || !framable || !current || !onCropChange || !natural) return;
const zoomBy = (factor: number) => {
if (!current || !natural || !onCropChange) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const rect = element.getBoundingClientRect();
const base = centredCrop(natural.w / natural.h, slotAspect);
const base = centredCrop(natural.w / natural.h, slotAspect);
// Fit is the floor — there is no more picture — and 8x is where a rendition runs out of pixels.
const zoom = Math.min(8, Math.max(1, (base.w / current.w) * factor));
// Where the pointer is in the picture, before anything moves. This is the point that must not.
const fx = (event.clientX - rect.left) / rect.width;
const fy = (event.clientY - rect.top) / rect.height;
const px = current.x + fx * current.w;
const py = current.y + fy * current.h;
// A tenth per notch, and the sign follows the platform's own convention for "away is in".
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
// Fit is the floor and 8x is a sane ceiling: past it a rendition has no pixels left to show.
const zoom = Math.min(8, Math.max(1, (base.w / current.w) * factor));
const next: Crop = {
w: base.w / zoom,
h: base.h / zoom,
x: px - fx * (base.w / zoom),
y: py - fy * (base.h / zoom),
};
next.x = Math.min(100 - next.w, Math.max(0, next.x));
next.y = Math.min(100 - next.h, Math.max(0, next.y));
// At fit exactly there is nothing to remember, so the crop is cleared rather than written as the
// centred value. A null crop and a crop that happens to be centred should not be two states.
onCropChange(zoom <= 1.001 ? null : formatCrop(next));
const w = base.w / zoom;
const h = base.h / zoom;
const next: Crop = {
w,
h,
// The centre held: the window shrinks around the point it was already centred on.
x: current.x + current.w / 2 - w / 2,
y: current.y + current.h / 2 - h / 2,
};
next.x = Math.min(100 - w, Math.max(0, next.x));
next.y = Math.min(100 - h, Math.max(0, next.y));
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [framable, current, onCropChange, natural, slotAspect]);
// At fit there is nothing to remember, so the crop is cleared rather than stored as the centred
// value. A null crop and a crop that happens to be centred should not be two states.
onCropChange(zoom <= 1.001 ? null : formatCrop(next));
};
/** How far in this is, as a multiple of fit. 1 means the whole picture, and there is no zooming out. */
const zoomLevel = current && natural ? centredCrop(natural.w / natural.h, slotAspect).w / current.w : 1;
/*
1280, not a thumbnail.
@@ -344,7 +339,39 @@ export const MediaField = ({
by a pointer.
*/}
{value && !broken && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
{/* Zoom first, and grouped, because they are the pair somebody presses repeatedly — a
destructive Remove between them would be pressed by accident eventually. */}
{framable && (
<div className="flex items-center gap-0.5 rounded-md bg-background/90 p-0.5 shadow">
<Button
size="sm"
variant="ghost"
className="size-6 p-0"
onClick={() => zoomBy(1 / 1.25)}
disabled={zoomLevel <= 1.001}
title="Zoom out"
>
<Minus className="size-3" />
</Button>
{/* The level, so a press that changes nothing at the limits is explained rather than
read as a dead button. */}
<span className="w-9 text-center text-[11px] tabular-nums text-muted-foreground">
{zoomLevel.toFixed(1)}×
</span>
<Button
size="sm"
variant="ghost"
className="size-6 p-0"
onClick={() => zoomBy(1.25)}
disabled={zoomLevel >= 7.99}
title="Zoom in"
>
<Plus className="size-3" />
</Button>
</div>
)}
<Button
size="sm"
variant="secondary"