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 6a526e9d..45abca96 100644
--- a/src/web/public/keyboard-accessory.js
+++ b/src/web/public/keyboard-accessory.js
@@ -74,6 +74,8 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
+ _previewImageCleanup: null,
+ _previewImageZoom: null,
_showHidden: false,
_sortMode: PATH_PICKER_DEFAULT_SORT,
@@ -563,6 +565,11 @@ const PathPicker = {
+
+
+
+
+
Open
@@ -580,13 +587,30 @@ const PathPicker = {
const body = overlay.querySelector('.path-preview-body');
if (entry.previewKind === 'image') {
+ const zoomControls = overlay.querySelector('.path-preview-zoom');
+ body.classList.add('path-preview-image-viewport');
const image = document.createElement('img');
image.className = 'path-preview-image';
image.alt = entry.name;
- image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
- image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
+ 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', () => {
+ 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);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
@@ -630,8 +654,142 @@ const PathPicker = {
body.replaceChildren(error);
},
+ _setPreviewImageZoom(scale, focalPoint) {
+ const state = this._previewImageZoom;
+ if (!state) return;
+ const nextScale = Math.max(1, Math.min(6, scale));
+ if (focalPoint) {
+ const rect = state.body.getBoundingClientRect();
+ const qx = focalPoint.x - (rect.left + rect.width / 2);
+ const qy = focalPoint.y - (rect.top + rect.height / 2);
+ const ratio = nextScale / state.scale;
+ state.x = qx + (state.x - qx) * ratio;
+ state.y = qy + (state.y - qy) * ratio;
+ }
+ state.scale = nextScale;
+ this._clampPreviewImagePan();
+ state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
+ const fitButton = state.controls.querySelector('[data-zoom="fit"]');
+ if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`;
+ 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 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;
+ const cleanups = [];
+ const listen = (target, name, handler, options) => {
+ target.addEventListener(name, handler, options);
+ cleanups.push(() => target.removeEventListener(name, handler, options));
+ };
+ const pointer = (event) => ({ x: event.clientX, y: event.clientY });
+ const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
+ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+ const startGesture = () => {
+ const points = Array.from(state.pointers.values());
+ if (points.length > 1) {
+ state.gesture = {
+ kind: 'pinch',
+ scale: state.scale,
+ distance: Math.max(1, distance(points[0], points[1])),
+ midpoint: midpoint(points[0], points[1]),
+ };
+ } else if (points.length === 1) {
+ state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y };
+ }
+ };
+ listen(image, 'pointerdown', (event) => {
+ event.preventDefault();
+ image.setPointerCapture?.(event.pointerId);
+ state.pointers.set(event.pointerId, pointer(event));
+ startGesture();
+ });
+ listen(image, 'pointermove', (event) => {
+ if (!state.pointers.has(event.pointerId) || !state.gesture) return;
+ event.preventDefault();
+ state.pointers.set(event.pointerId, pointer(event));
+ if (state.pointers.size > 1) {
+ const points = Array.from(state.pointers.values());
+ this._setPreviewImageZoom(
+ state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance,
+ state.gesture.midpoint
+ );
+ } else if (state.gesture.kind === 'pan' && state.scale > 1) {
+ const point = state.pointers.values().next().value;
+ state.x = state.gesture.x + point.x - state.gesture.point.x;
+ state.y = state.gesture.y + point.y - state.gesture.point.y;
+ this._setPreviewImageZoom(state.scale);
+ }
+ });
+ const endPointer = (event) => {
+ state.pointers.delete(event.pointerId);
+ startGesture();
+ };
+ listen(image, 'pointerup', endPointer);
+ listen(image, 'pointercancel', endPointer);
+ listen(image, 'lostpointercapture', endPointer);
+ listen(image, 'dblclick', (event) => {
+ event.preventDefault();
+ this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event));
+ });
+ listen(body, 'wheel', (event) => {
+ event.preventDefault();
+ 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]');
+ if (!button) return;
+ if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1);
+ else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5));
+ });
+ this._previewImageCleanup = () => {
+ cleanups.forEach((cleanup) => cleanup());
+ this._previewImageZoom = null;
+ this._previewImageCleanup = null;
+ };
+ },
+
closePreview(restoreFocus = true) {
this._previewRequestSequence += 1;
+ this._previewImageCleanup?.();
this._previewOverlay?.remove();
this._previewOverlay = null;
const previousFocus = this._previewPreviousFocus;
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index af663547..7e426707 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -16218,6 +16218,29 @@ body.touch-device.cjk-input-visible .main {
text-decoration: none;
}
+.path-preview-zoom {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 3px;
+}
+
+.path-preview-zoom[hidden] {
+ display: none;
+}
+
+.path-preview-zoom button {
+ min-width: 32px;
+ height: 34px;
+ padding: 0 6px;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
.path-preview-close {
width: 38px;
height: 38px;
@@ -16236,6 +16259,12 @@ body.touch-device.cjk-input-visible .main {
background: #111;
}
+.path-preview-body.path-preview-image-viewport {
+ overflow: hidden;
+ touch-action: none;
+ overscroll-behavior: contain;
+}
+
.path-preview-loading,
.path-preview-error {
padding: 24px;
@@ -16250,10 +16279,23 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
- max-width: 100%;
- max-height: 100%;
- margin: auto;
- object-fit: contain;
+ width: 100%;
+ height: 100%;
+ /* 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;
+ cursor: zoom-in;
+}
+
+.path-preview-image.is-zoomed {
+ cursor: grab;
+}
+
+.path-preview-image.is-zoomed:active {
+ cursor: grabbing;
}
.path-preview-frame {
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) => {
+ const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
+ target.dispatchEvent(event);
+ return event;
+ };
+
+ /** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */
+ const pointer = (target: EventTarget, type: string, id: number, p: Point) => {
+ const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y });
+ Object.defineProperty(event, 'pointerId', { value: id });
+ target.dispatchEvent(event);
+ };
+
+ const dblclick = (target: EventTarget, p: Point) =>
+ target.dispatchEvent(
+ new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y })
+ );
+
+ it('shows the controls only once the image has loaded, and the buttons step the zoom', () => {
+ const { image, controls, button } = openImage();
+ expect(controls.hidden).toBe(true);
+
+ fire(image, 'load');
+ expect(controls.hidden).toBe(false);
+ expect(button('fit').textContent).toBe('100%');
+
+ button('in').click();
+ expect(state().scale).toBeCloseTo(1.5);
+ expect(button('fit').textContent).toBe('150%');
+ expect(image.classList.contains('is-zoomed')).toBe(true);
+
+ button('fit').click();
+ expect(state()).toMatchObject({ scale: 1 });
+ expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
+ expect(button('fit').textContent).toBe('100%');
+ expect(image.classList.contains('is-zoomed')).toBe(false);
+
+ button('out').click();
+ expect(state().scale).toBe(1);
+ });
+
+ it('wheel zoom keeps the image point under the cursor where it was', () => {
+ const { body, image } = openImage();
+ fire(image, 'load');
+
+ for (const cursor of [
+ { x: 300, y: 100 },
+ { x: 120, y: 220 },
+ { x: 390, y: 290 },
+ ]) {
+ const before = imagePointUnder(cursor);
+ const scaleBefore = state().scale;
+ wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y });
+ expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2);
+ const after = imagePointUnder(cursor);
+ expect(after.x).toBeCloseTo(before.x);
+ expect(after.y).toBeCloseTo(before.y);
+ }
+ expect(image.style.transform).toContain(`scale(${state().scale})`);
+ });
+
+ it('pinch zooms by the finger distance around the pinch centre', () => {
+ const { image } = openImage();
+ fire(image, 'load');
+ const centre = { x: 150, y: 150 };
+ const before = imagePointUnder(centre);
+
+ pointer(image, 'pointerdown', 1, { x: 100, y: 150 });
+ pointer(image, 'pointerdown', 2, { x: 200, y: 150 });
+ pointer(image, 'pointermove', 2, { x: 300, y: 150 });
+
+ expect(state().scale).toBeCloseTo(2);
+ const after = imagePointUnder(centre);
+ expect(after.x).toBeCloseTo(before.x);
+ expect(after.y).toBeCloseTo(before.y);
+
+ pointer(image, 'pointerup', 2, { x: 300, y: 150 });
+ pointer(image, 'pointerup', 1, { x: 100, y: 150 });
+ expect(state().pointers.size).toBe(0);
+ });
+
+ it('pans a zoomed image only until its edge meets the viewport edge', () => {
+ const { image } = openImage();
+ fire(image, 'load');
+ dblclick(image, CENTRE);
+ expect(state().scale).toBe(2);
+
+ pointer(image, 'pointerdown', 1, CENTRE);
+ pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
+ // 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack.
+ expect(state()).toMatchObject({ x: 200, y: 150 });
+ pointer(image, 'pointermove', 1, { x: -5000, y: -5000 });
+ expect(state()).toMatchObject({ x: -200, y: -150 });
+ pointer(image, 'pointerup', 1, { x: -5000, y: -5000 });
+
+ // Without zoom there is nothing to pan.
+ dblclick(image, CENTRE);
+ pointer(image, 'pointerdown', 1, CENTRE);
+ pointer(image, 'pointermove', 1, { x: 300, y: 250 });
+ expect(state()).toMatchObject({ scale: 1 });
+ expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
+ });
+
+ it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => {
+ // 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box.
+ const { image } = openImage({ width: 100, height: 50 });
+ fire(image, 'load');
+
+ dblclick(image, { x: 380, y: 280 });
+ expect(state().scale).toBe(2);
+ pointer(image, 'pointerdown', 1, CENTRE);
+ pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
+ // 200x100 still fits inside 400x300 on both axes: it stays centred.
+ expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0);
+ pointer(image, 'pointerup', 1, { x: 5000, y: 5000 });
+
+ PathPicker._setPreviewImageZoom(6);
+ pointer(image, 'pointerdown', 1, CENTRE);
+ pointer(image, 'pointermove', 1, { x: 5000, y: 5000 });
+ // 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would
+ // have allowed 1000 and 750, carrying the image entirely out of the viewport.
+ expect(state().x).toBe(100);
+ expect(Math.abs(state().y)).toBe(0);
+ });
+
+ it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => {
+ const factor = (init: Record) => PathPicker._previewWheelZoomFactor(init);
+ expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1);
+ expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008);
+ expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2);
+ expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2);
+ // Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored.
+ expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15);
+ expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2);
+ expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2);
+ expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2);
+
+ const { body, image } = openImage();
+ fire(image, 'load');
+ const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
+ const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 });
+ expect(sideways.defaultPrevented).toBe(true);
+ expect(zoom).not.toHaveBeenCalled();
+ expect(state().scale).toBe(1);
+
+ // A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap.
+ for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 });
+ expect(state().scale).toBeCloseTo(2 ** 0.24);
+ expect(state().scale).toBeLessThan(1.25);
+ });
+
+ it('hides the controls and stops zooming when the image fails to load', () => {
+ const { body, image, controls } = openImage();
+ fire(image, 'error');
+
+ expect(controls.hidden).toBe(true);
+ expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load');
+ expect(PathPicker._previewImageZoom).toBeNull();
+
+ const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
+ wheel(body, { deltaY: -100, clientX: 200, clientY: 150 });
+ expect(zoom).not.toHaveBeenCalled();
+ });
+
+ it('closing drops the zoom state and every listener, and the next image starts at 100%', () => {
+ const first = openImage();
+ fire(first.image, 'load');
+ dblclick(first.image, { x: 300, y: 100 });
+ expect(state().scale).toBe(2);
+ const transformAtClose = first.image.style.transform;
+
+ PathPicker.closePreview(false);
+ expect(PathPicker._previewImageZoom).toBeNull();
+ expect(PathPicker._previewImageCleanup).toBeNull();
+ expect(first.overlay.isConnected).toBe(false);
+
+ const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom');
+ wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 });
+ dblclick(first.image, CENTRE);
+ first.button('in').click();
+ expect(zoom).not.toHaveBeenCalled();
+ expect(first.image.style.transform).toBe(transformAtClose);
+ zoom.mockRestore();
+
+ const second = openImage({ width: 800, height: 600 }, 'next.png');
+ fire(second.image, 'load');
+ expect(state()).toMatchObject({ scale: 1 });
+ expect(second.button('fit').textContent).toBe('100%');
+ expect(second.image.classList.contains('is-zoomed')).toBe(false);
+ });
+
+ it('a late load or error from a replaced preview leaves the current one alone', () => {
+ const stale = openImage({ width: 800, height: 600 }, 'old.png');
+ const current = openImage({ width: 800, height: 600 }, 'new.png');
+ fire(current.image, 'load');
+ dblclick(current.image, { x: 300, y: 100 });
+ const zoomed = { ...state() };
+
+ fire(stale.image, 'load');
+ expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y });
+
+ fire(stale.image, 'error');
+ expect(current.body.querySelector('.path-preview-error')).toBeNull();
+ expect(current.image.isConnected).toBe(true);
+ expect(current.controls.hidden).toBe(false);
+ expect(PathPicker._previewImageZoom).not.toBeNull();
+ });
+
+ it('draws a small image at its own size rather than stretching it (scale-down)', () => {
+ const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/);
+ expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/);
+ expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/);
+ });
+});