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 && ( -