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();
+ });
+});