From ba6bff388a6118670898e6f9aa60c6267c885b2e Mon Sep 17 00:00:00 2001
From: shenlvkang-collab <258506731+shenlvkang-collab@users.noreply.github.com>
Date: Mon, 9 Nov 2026 02:33:16 +0100
Subject: [PATCH 1/2] feat(path-picker): pinch, pan and wheel zoom for the
image preview
The path picker's image preview gets zoom controls (out, fit/percentage,
in), a two-finger pinch that keeps the pinch centre in place, one-finger
pan while zoomed (clamped to the image bounds), double-click to toggle
2x, and wheel zoom on desktop. closePreview() removes every listener and
drops the zoom state.
Salvaged from #608.
---
src/web/public/keyboard-accessory.js | 123 ++++++++++++++++++++++++++-
src/web/public/styles.css | 46 +++++++++-
test/path-picker-ui.test.ts | 9 ++
3 files changed, 174 insertions(+), 4 deletions(-)
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js
index 6a526e9d..4eecf767 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,21 @@ 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.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
+ image.draggable = false;
+ image.addEventListener('load', () => {
+ body.querySelector('.path-preview-loading')?.remove();
+ this._setPreviewImageZoom(1);
+ });
image.addEventListener('error', () => 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 +645,114 @@ 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);
+ },
+
+ _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;
+ state.x = Math.max(-maxX, Math.min(maxX, state.x));
+ state.y = Math.max(-maxY, Math.min(maxY, state.y));
+ },
+
+ _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();
+ this._setPreviewImageZoom(state.scale * (event.deltaY < 0 ? 1.2 : 1 / 1.2), 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..9c45371a 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,21 @@ body.touch-device.cjk-input-visible .main {
.path-preview-image {
display: block;
- max-width: 100%;
- max-height: 100%;
- margin: auto;
+ width: 100%;
+ height: 100%;
object-fit: contain;
+ 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-ui.test.ts b/test/path-picker-ui.test.ts
index 6fa30b3a..c21889c7 100644
--- a/test/path-picker-ui.test.ts
+++ b/test/path-picker-ui.test.ts
@@ -169,6 +169,15 @@ describe('mobile filesystem picker actions', () => {
expect(keyboardSource).toContain('pre.textContent = content');
});
+ it('supports pinch zoom, pan, and explicit zoom controls in image previews', () => {
+ expect(keyboardSource).toContain("entry.previewKind === 'image'");
+ expect(keyboardSource).toContain("listen(image, 'pointerdown'");
+ expect(keyboardSource).toContain("listen(image, 'pointermove'");
+ expect(keyboardSource).toContain("listen(image, 'dblclick'");
+ expect(keyboardSource).toContain('data-zoom="fit"');
+ expect(keyboardSource).toContain('this._previewImageCleanup?.()');
+ });
+
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
const appendText = vi.fn();
const sendInput = vi.fn();
From 572ff202bb3564ff26bfff3a9f04111ef78fecda Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Mon, 9 Nov 2026 02:43:03 +0100
Subject: [PATCH 2/2] fix(path-picker): finish the image preview zoom from #608
The review items offered on #608, applied on top of the contributor's
zoom:
- `object-fit: scale-down` instead of `contain`, so a small image keeps
its own size instead of being stretched to fill the preview. The pan
clamp now uses the size the image is actually drawn at (natural size
fitted with scale-down) rather than the image box, which let a small
or letterboxed image be panned entirely out of view.
- Wheel zoom is proportional to deltaY (d3-zoom's scale: about 15% per
100px notch, lines and ctrl+wheel trackpad pinches scaled up), capped
at sqrt(2) per event, and a wheel event with no vertical delta zooms
nothing. A trackpad scroll no longer jumps to 600%.
- The zoom controls show only once the image has loaded; on an error
they are hidden and the gesture listeners are dropped with the image.
Both handlers now check the request sequence like the text branch, so
a late load or error from a replaced preview no longer resets the
current preview's zoom or paints its error over the current image.
- test/path-picker-image-zoom.test.ts drives the real listeners in
jsdom (focal point under the cursor and the pinch centre, pan clamp,
wheel scaling, error, close and stale events) in place of the
source-string checks in test/path-picker-ui.test.ts. 7 of its 10
tests fail against the contributor's commit alone.
Co-Authored-By: Claude Opus 5.5
---
.changeset/path-picker-image-zoom.md | 5 +
src/web/public/keyboard-accessory.js | 47 +++-
src/web/public/styles.css | 4 +-
test/path-picker-image-zoom.test.ts | 309 +++++++++++++++++++++++++++
test/path-picker-ui.test.ts | 9 -
5 files changed, 359 insertions(+), 15 deletions(-)
create mode 100644 .changeset/path-picker-image-zoom.md
create mode 100644 test/path-picker-image-zoom.test.ts
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) => {
+ 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;/);
+ });
+});
diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts
index c21889c7..6fa30b3a 100644
--- a/test/path-picker-ui.test.ts
+++ b/test/path-picker-ui.test.ts
@@ -169,15 +169,6 @@ describe('mobile filesystem picker actions', () => {
expect(keyboardSource).toContain('pre.textContent = content');
});
- it('supports pinch zoom, pan, and explicit zoom controls in image previews', () => {
- expect(keyboardSource).toContain("entry.previewKind === 'image'");
- expect(keyboardSource).toContain("listen(image, 'pointerdown'");
- expect(keyboardSource).toContain("listen(image, 'pointermove'");
- expect(keyboardSource).toContain("listen(image, 'dblclick'");
- expect(keyboardSource).toContain('data-zoom="fit"');
- expect(keyboardSource).toContain('this._previewImageCleanup?.()');
- });
-
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
const appendText = vi.fn();
const sendInput = vi.fn();