diff --git a/.changeset/path-picker-image-zoom.md b/.changeset/path-picker-image-zoom.md new file mode 100644 index 00000000..58c82c8e --- /dev/null +++ b/.changeset/path-picker-image-zoom.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +The path picker's image preview can now be zoomed: pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click to toggle 2x, and zoom out / fit / zoom in buttons. A small image is shown at its own size instead of being stretched to fill the preview. Thanks to @shenlvkang-collab, whose #608 this comes from. diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 4eecf767..45abca96 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -588,17 +588,26 @@ const PathPicker = { const body = overlay.querySelector('.path-preview-body'); if (entry.previewKind === 'image') { const zoomControls = overlay.querySelector('.path-preview-zoom'); - zoomControls.hidden = false; body.classList.add('path-preview-image-viewport'); const image = document.createElement('img'); image.className = 'path-preview-image'; image.alt = entry.name; image.draggable = false; + // Both handlers check the sequence like the text branch: an image whose preview was already + // replaced can still finish loading, and `this._previewOverlay` and the zoom state are then + // the newer preview's. The controls show only once there is an image to zoom. image.addEventListener('load', () => { + if (requestSequence !== this._previewRequestSequence) return; body.querySelector('.path-preview-loading')?.remove(); + zoomControls.hidden = false; this._setPreviewImageZoom(1); }); - image.addEventListener('error', () => this.showPreviewError('Image preview failed to load')); + image.addEventListener('error', () => { + if (requestSequence !== this._previewRequestSequence) return; + zoomControls.hidden = true; + this._previewImageCleanup?.(); + this.showPreviewError('Image preview failed to load'); + }); image.src = previewUrl; body.appendChild(image); this._bindPreviewImageGestures(image, body, zoomControls); @@ -665,16 +674,43 @@ const PathPicker = { state.image.classList.toggle('is-zoomed', state.scale > 1); }, + /** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never + * larger than its natural size. The box until the image has loaded (no natural size yet). */ + _previewImageFitSize(state, rect) { + const naturalWidth = state.image.naturalWidth; + const naturalHeight = state.image.naturalHeight; + if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height }; + const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight); + return { width: naturalWidth * fit, height: naturalHeight * fit }; + }, + + /** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's + * edge, never further, and an axis on which it is still smaller than the viewport stays centred. + * Clamping to the image box instead let a small or letterboxed image be panned out of view. */ _clampPreviewImagePan() { const state = this._previewImageZoom; if (!state) return; const rect = state.body.getBoundingClientRect(); - const maxX = rect.width * (state.scale - 1) / 2; - const maxY = rect.height * (state.scale - 1) / 2; + const fitted = this._previewImageFitSize(state, rect); + const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2); + const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2); state.x = Math.max(-maxX, Math.min(maxX, state.x)); state.y = Math.max(-maxY, Math.min(maxY, state.y)); }, + /** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per + * 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a + * trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per + * event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta + * (a sideways scroll) zooms nothing. */ + _previewWheelZoomFactor(event) { + const deltaY = Number(event?.deltaY) || 0; + if (!deltaY) return 1; + const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002; + const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1); + return 2 ** Math.max(-0.5, Math.min(0.5, exponent)); + }, + _bindPreviewImageGestures(image, body, controls) { this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null }; const state = this._previewImageZoom; @@ -735,7 +771,8 @@ const PathPicker = { }); listen(body, 'wheel', (event) => { event.preventDefault(); - this._setPreviewImageZoom(state.scale * (event.deltaY < 0 ? 1.2 : 1 / 1.2), pointer(event)); + const factor = this._previewWheelZoomFactor(event); + if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event)); }, { passive: false }); listen(controls, 'click', (event) => { const button = event.target.closest('[data-zoom]'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 9c45371a..7e426707 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16281,7 +16281,9 @@ body.touch-device.cjk-input-visible .main { display: block; width: 100%; height: 100%; - object-fit: contain; + /* scale-down, not contain: a small image keeps its own size instead of being blown up to the + viewport (the zoom controls are there to enlarge it). */ + object-fit: scale-down; touch-action: none; user-select: none; -webkit-user-drag: none; diff --git a/test/path-picker-image-zoom.test.ts b/test/path-picker-image-zoom.test.ts new file mode 100644 index 00000000..4a6473be --- /dev/null +++ b/test/path-picker-image-zoom.test.ts @@ -0,0 +1,309 @@ +/** + * @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan, + * double-click, wheel and the out / fit / in buttons. Same jsdom harness as + * path-picker-hidden.test.ts (see its header for why the window is built by hand). + * + * These drive the real listeners and read the zoom state back, because what can go wrong is + * geometric, not textual: + * + * 1. the point under the cursor or between the fingers drifting while the scale changes, + * 2. a pan that carries the image out of view (a small image is drawn at its own size with + * `object-fit: scale-down`, so the image box is not the image), + * 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did, + * 4. controls left on screen for an image that failed to load, + * 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced + * preview acting on the current one. + * + * jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size + * are stubbed per test and `load`/`error` are dispatched by hand. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8'); +const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); + +const dom = new JSDOM('
', { url: 'https://localhost/' }); +const jsdomWindow = dom.window as unknown as Window & typeof globalThis; +const doc = jsdomWindow.document; + +/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */ +const BOX = { left: 0, top: 0, width: 400, height: 300 }; +const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 }; + +type Point = { x: number; y: number }; +type ZoomState = { scale: number; x: number; y: number }; + +function loadPathPicker(): any { + const factory = new Function( + 'window', + 'document', + 'localStorage', + 'fetch', + 'MobileDetection', + `${accessoryJs}\nreturn PathPicker;` + ); + return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false }); +} + +describe('PathPicker image preview zoom', () => { + let PathPicker: any; + + beforeEach(() => { + doc.body.replaceChildren(); + PathPicker = loadPathPicker(); + }); + + afterEach(() => { + PathPicker?.closePreview?.(false); + doc.body.replaceChildren(); + }); + + /** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */ + const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => { + PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' }); + const overlay = PathPicker._previewOverlay as HTMLElement; + const body = overlay.querySelector('.path-preview-body') as HTMLElement; + body.getBoundingClientRect = () => + ({ + ...BOX, + x: BOX.left, + y: BOX.top, + right: BOX.left + BOX.width, + bottom: BOX.top + BOX.height, + toJSON: () => ({}), + }) as DOMRect; + const image = body.querySelector('img.path-preview-image') as HTMLImageElement; + Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true }); + Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true }); + const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement; + const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement; + return { overlay, body, image, controls, button }; + }; + + const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type)); + const state = (): ZoomState => PathPicker._previewImageZoom; + + /** Where, in the unzoomed image, the given screen point lands under the current transform. */ + const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({ + x: (p.x - CENTRE.x - s.x) / s.scale, + y: (p.y - CENTRE.y - s.y) / s.scale, + }); + + const wheel = (target: EventTarget, init: Record