From 78eff979e204dccddf266b050c9eac3df71f9053 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Wed, 14 Oct 2026 10:44:39 -0400 Subject: [PATCH] Fix canvas pinch-to-zoom gestures --- app/components/Preview.vue | 2 + app/composables/usePanZoom.js | 20 +- tests/composables/usePanZoomGestures.test.js | 237 +++++++++++++++++++ 3 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tests/composables/usePanZoomGestures.test.js diff --git a/app/components/Preview.vue b/app/components/Preview.vue index 5491d68..b9d0e58 100644 --- a/app/components/Preview.vue +++ b/app/components/Preview.vue @@ -277,6 +277,8 @@ const { zoom, zoomTo, centerGuides, createPanZoom, resetViewport } = usePanZoom( centerSnapThreshold: 12, centerGuideThreshold: 24, cursor: 'grab', + minScale: 0.2, + maxScale: 2, excludeClass: 'exclude-from-panzoom', }); diff --git a/app/composables/usePanZoom.js b/app/composables/usePanZoom.js index 3dcc77f..f41b518 100644 --- a/app/composables/usePanZoom.js +++ b/app/composables/usePanZoom.js @@ -7,6 +7,7 @@ export default function (viewport, options = {}) { const panzoom = ref(null); const centerGuides = reactive({ x: false, y: false }); let element = null; + let surface = null; let dragging = false; const { @@ -70,8 +71,21 @@ export default function (viewport, options = {}) { syncViewport(); } + function onPanZoomZoom(event) { + zoom.value = event.detail.scale; + syncViewport(); + } + + function onWheel(event) { + if (!event.ctrlKey) return; + + // Trackpad pinches emit Ctrl+wheel events with small, continuous deltas. + panzoom.value.zoomWithWheel(event, { step: Math.abs(event.deltaY) * 0.03 }); + } + function createPanZoom(elementRef) { element = unref(elementRef); + surface = element.parentElement; panzoom.value = Panzoom(element, { ...panzoomOptions, @@ -83,7 +97,8 @@ export default function (viewport, options = {}) { element.addEventListener('panzoomstart', onPanZoomStart); element.addEventListener('panzoompan', onPanZoomPan); element.addEventListener('panzoomend', onPanZoomEnd); - element.addEventListener('panzoomzoom', syncViewport); + element.addEventListener('panzoomzoom', onPanZoomZoom); + surface.addEventListener('wheel', onWheel, { passive: false }); } function destroyPanZoom() { @@ -91,7 +106,8 @@ export default function (viewport, options = {}) { element.removeEventListener('panzoomstart', onPanZoomStart); element.removeEventListener('panzoompan', onPanZoomPan); element.removeEventListener('panzoomend', onPanZoomEnd); - element.removeEventListener('panzoomzoom', syncViewport); + element.removeEventListener('panzoomzoom', onPanZoomZoom); + surface.removeEventListener('wheel', onWheel); } dragging = false; diff --git a/tests/composables/usePanZoomGestures.test.js b/tests/composables/usePanZoomGestures.test.js new file mode 100644 index 0000000..be48c6e --- /dev/null +++ b/tests/composables/usePanZoomGestures.test.js @@ -0,0 +1,237 @@ +import { mount } from '@vue/test-utils'; +import { onMounted, ref } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import usePanZoom from '~/composables/usePanZoom'; + +// Use the browser ESM build so these tests exercise real Panzoom gestures. +vi.mock('@panzoom/panzoom', () => import('@panzoom/panzoom/dist/panzoom.es.js')); + +describe('canvas zoom gestures', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Happy DOM's WheelEvent omits the inherited mouse coordinates and modifiers. + vi.stubGlobal( + 'WheelEvent', + class extends MouseEvent { + constructor(type, options) { + super(type, options); + this.deltaY = options.deltaY ?? 0; + } + } + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('zooms smoothly around the pointer and saves the resulting viewport', async () => { + const viewport = { x: 0, y: 0, zoom: 1 }; + const wrapper = mount( + { + setup() { + const preview = ref(null); + const controls = usePanZoom(viewport); + onMounted(() => controls.createPanZoom(preview)); + return { preview, ...controls }; + }, + template: '
', + }, + { attachTo: document.body } + ); + vi.spyOn(wrapper.element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 600, + bottom: 400, + width: 600, + height: 400, + }); + await vi.advanceTimersByTimeAsync(350); + + const pinch = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -10, + clientX: 200, + clientY: 100, + bubbles: true, + cancelable: true, + }); + wrapper.element.dispatchEvent(pinch); + await vi.advanceTimersByTimeAsync(350); + + const enlarged = wrapper.vm.panzoom.getScale(); + expect(pinch.defaultPrevented).toBe(true); + expect(enlarged).toBeGreaterThan(1); + expect(enlarged).toBeLessThan(1.2); + expect(wrapper.vm.zoom).toBe(enlarged); + expect(viewport).toEqual({ ...wrapper.vm.panzoom.getPan(), zoom: enlarged }); + expect(viewport.x).not.toBe(0); + expect(viewport.y).not.toBe(0); + + wrapper.element.firstElementChild.dispatchEvent( + new WheelEvent('wheel', { + ctrlKey: true, + deltaY: 10, + clientX: 200, + clientY: 100, + bubbles: true, + cancelable: true, + }) + ); + await vi.advanceTimersByTimeAsync(350); + + expect(wrapper.vm.zoom).toBeCloseTo(1); + expect(viewport.zoom).toBeCloseTo(1); + wrapper.unmount(); + }); + + it('leaves ordinary scrolling and gestures outside the canvas alone and removes its listener', async () => { + const viewport = { zoom: 1 }; + const wrapper = mount( + { + setup() { + const preview = ref(null); + const controls = usePanZoom(viewport); + onMounted(() => controls.createPanZoom(preview)); + return { preview, ...controls }; + }, + template: '
', + }, + { attachTo: document.body } + ); + await vi.advanceTimersByTimeAsync(350); + const scroll = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true }); + const outsidePinch = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -10, + bubbles: true, + cancelable: true, + }); + + wrapper.element.dispatchEvent(scroll); + document.body.dispatchEvent(outsidePinch); + + expect(scroll.defaultPrevented).toBe(false); + expect(outsidePinch.defaultPrevented).toBe(false); + expect(wrapper.vm.panzoom.getScale()).toBe(1); + + const surface = wrapper.element; + wrapper.unmount(); + const pinch = new WheelEvent('wheel', { ctrlKey: true, deltaY: -10, cancelable: true }); + surface.dispatchEvent(pinch); + expect(pinch.defaultPrevented).toBe(false); + }); + + it.each([ + [-1000, 2], + [1000, 0.2], + ])('clamps a pinch delta of %s to scale %s', async (deltaY, scale) => { + const viewport = { zoom: 1 }; + const wrapper = mount( + { + setup() { + const preview = ref(null); + const controls = usePanZoom(viewport, { minScale: 0.2, maxScale: 2 }); + onMounted(() => controls.createPanZoom(preview)); + return { preview, ...controls }; + }, + template: '
', + }, + { attachTo: document.body } + ); + vi.spyOn(wrapper.element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 600, + bottom: 400, + width: 600, + height: 400, + }); + await vi.advanceTimersByTimeAsync(350); + + wrapper.element.dispatchEvent( + new WheelEvent('wheel', { + ctrlKey: true, + deltaY, + clientX: 200, + clientY: 100, + cancelable: true, + }) + ); + await vi.advanceTimersByTimeAsync(350); + + expect(wrapper.vm.panzoom.getScale()).toBe(scale); + expect(wrapper.vm.zoom).toBe(scale); + expect(viewport.zoom).toBe(scale); + wrapper.unmount(); + }); + + it('keeps the zoom controls and saved viewport in sync with a two-finger touch pinch', async () => { + const viewport = { zoom: 1 }; + const wrapper = mount( + { + setup() { + const preview = ref(null); + const controls = usePanZoom(viewport); + onMounted(() => controls.createPanZoom(preview)); + return { preview, ...controls }; + }, + template: '
', + }, + { attachTo: document.body } + ); + vi.spyOn(wrapper.element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 600, + bottom: 400, + width: 600, + height: 400, + }); + await vi.advanceTimersByTimeAsync(350); + const element = wrapper.element.firstElementChild; + + element.dispatchEvent( + new PointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'touch', + clientX: 100, + clientY: 100, + bubbles: true, + }) + ); + element.dispatchEvent( + new PointerEvent('pointerdown', { + pointerId: 2, + pointerType: 'touch', + clientX: 200, + clientY: 100, + bubbles: true, + }) + ); + document.dispatchEvent( + new PointerEvent('pointermove', { + pointerId: 2, + pointerType: 'touch', + clientX: 250, + clientY: 100, + }) + ); + await vi.advanceTimersByTimeAsync(350); + + expect(wrapper.vm.panzoom.getScale()).toBeGreaterThan(1); + expect(wrapper.vm.zoom).toBe(wrapper.vm.panzoom.getScale()); + expect(viewport.zoom).toBe(wrapper.vm.panzoom.getScale()); + wrapper.unmount(); + }); +});