From 25d953dfd0abbe3d27eef4b0f8cdc1a8ad270750 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Fri, 23 Oct 2026 13:54:11 +0200 Subject: [PATCH] feat(preview): zoom images with ctrl or cmd + mouse wheel Ctrl + mouse wheel was swallowed over the image without doing anything, because the wheel handler prevents the browser default before checking for shift. Accept ctrl and meta next to shift, which also enables trackpad pinch gestures since browsers deliver those as wheel events with ctrlKey set. Tooltips now advertise the conventional modifier. --- .../src/components/MediaControls.vue | 9 ++- .../src/components/Sources/MediaImage.vue | 2 +- .../tests/unit/components/MediaImage.spec.ts | 58 ++++++++++++++++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/web-app-preview/src/components/MediaControls.vue b/packages/web-app-preview/src/components/MediaControls.vue index 5f68ea337b6..1503d99c0d1 100644 --- a/packages/web-app-preview/src/components/MediaControls.vue +++ b/packages/web-app-preview/src/components/MediaControls.vue @@ -266,8 +266,13 @@ const resourceFavoriteIconDescription = computed(() => { const enterFullScreenDescription = computed(() => $gettext('Enter full screen mode')) const exitFullScreenDescription = computed(() => $gettext('Exit full screen mode')) -const imageShrinkDescription = computed(() => $gettext('Shrink the image (⇧ + Mouse wheel)')) -const imageZoomDescription = computed(() => $gettext('Enlarge the image (⇧ + Mouse wheel)')) +const zoomModifierKey = computed(() => (isMacOs() ? '⌘' : $gettext('Ctrl'))) +const imageShrinkDescription = computed(() => + $gettext('Shrink the image (%{key} + Mouse wheel)', { key: unref(zoomModifierKey) }) +) +const imageZoomDescription = computed(() => + $gettext('Enlarge the image (%{key} + Mouse wheel)', { key: unref(zoomModifierKey) }) +) const imageResetDescription = computed(() => $gettext('Reset')) const imageRotateLeftDescription = computed(() => $gettext('Rotate the image 90 degrees to the left') diff --git a/packages/web-app-preview/src/components/Sources/MediaImage.vue b/packages/web-app-preview/src/components/Sources/MediaImage.vue index 94f95ec2942..1df60d48ffd 100644 --- a/packages/web-app-preview/src/components/Sources/MediaImage.vue +++ b/packages/web-app-preview/src/components/Sources/MediaImage.vue @@ -76,7 +76,7 @@ function sanitizeSvgElement(svg: SVGElement) { const onWheelEvent = (e: WheelEvent) => { e.preventDefault() - if (!e.shiftKey) { + if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { return false } diff --git a/packages/web-app-preview/tests/unit/components/MediaImage.spec.ts b/packages/web-app-preview/tests/unit/components/MediaImage.spec.ts index 755c2a001a9..a3f41b45df2 100644 --- a/packages/web-app-preview/tests/unit/components/MediaImage.spec.ts +++ b/packages/web-app-preview/tests/unit/components/MediaImage.spec.ts @@ -1,17 +1,19 @@ import MediaImage from '../../../src/components/Sources/MediaImage.vue' import { defaultPlugins, shallowMount } from '@opencloud-eu/web-test-helpers' +import { flushPromises } from '@vue/test-utils' import { Resource } from '@opencloud-eu/web-client' import { MediaFile } from '../../../src/helpers/types' // panzoom touches real DOM geometry which jsdom does not provide -vi.mock('@panzoom/panzoom', () => ({ - default: () => ({ - destroy: vi.fn(), - setStyle: vi.fn(), - getScale: () => 1, - getPan: () => ({ x: 0, y: 0 }) - }) -})) +const panzoom = { + destroy: vi.fn(), + setStyle: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + getScale: () => 1, + getPan: () => ({ x: 0, y: 0 }) +} +vi.mock('@panzoom/panzoom', () => ({ default: () => panzoom })) const mediaFile = (mimeType: string): MediaFile => ({ @@ -29,7 +31,20 @@ const mediaFile = (mimeType: string): MediaFile => resource: { id: 'img1' } as Resource }) as MediaFile +// the test DOM drops modifier keys passed to the WheelEvent constructor +function wheelEvent(deltaY: number, modifier: Partial = {}) { + const event = new WheelEvent('wheel', { deltaY }) + Object.entries(modifier).forEach(([key, value]) => { + Object.defineProperty(event, key, { value }) + }) + return event +} + describe('MediaImage component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('fits an svg to the stage: stage-sized wrapper and a size-full svg', () => { // the wrapper must have a size so the svg can scale to it instead of // overflowing (large svg) or collapsing to 0x0 (viewBox-only svg) @@ -49,6 +64,33 @@ describe('MediaImage component', () => { const img = wrapper.find('img') expect(img.classes()).toEqual(expect.arrayContaining(['max-w-full', 'max-h-full'])) }) + + describe('mouse wheel zoom', () => { + it.each([ + ['shift', { shiftKey: true }], + ['ctrl', { ctrlKey: true }], + ['meta', { metaKey: true }] + ])('zooms with the %s key held', async (_, modifier) => { + const { wrapper } = getWrapper('image/png') + await flushPromises() + + wrapper.find('img').element.dispatchEvent(wheelEvent(-1, modifier)) + expect(panzoom.zoomIn).toHaveBeenCalledWith({ step: 0.1 }) + + wrapper.find('img').element.dispatchEvent(wheelEvent(1, modifier)) + expect(panzoom.zoomOut).toHaveBeenCalledWith({ step: 0.1 }) + }) + + it('does not zoom without a modifier key', async () => { + const { wrapper } = getWrapper('image/png') + await flushPromises() + + wrapper.find('img').element.dispatchEvent(wheelEvent(-1)) + + expect(panzoom.zoomIn).not.toHaveBeenCalled() + expect(panzoom.zoomOut).not.toHaveBeenCalled() + }) + }) }) function getWrapper(mimeType: string) {