From 80a7d2683ff4823b11d12acdc1f6e0eeaf1f5acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 00:07:30 +0100 Subject: [PATCH] zoom is a pair of buttons, and the wheel belongs to the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/MediaField.tsx | 117 ++++++++++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 45 deletions(-) diff --git a/web/MediaField.tsx b/web/MediaField.tsx index a69e85e..901fa37 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -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 && ( -
+
+ {/* Zoom first, and grouped, because they are the pair somebody presses repeatedly — a + destructive Remove between them would be pressed by accident eventually. */} + {framable && ( +
+ + {/* The level, so a press that changes nothing at the limits is explained rather than + read as a dead button. */} + + {zoomLevel.toFixed(1)}× + + +
+ )} +