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] 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();