From 2bc70a3ec6b5b07d5a62b62db40c2af2727eec82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:13:15 +0200 Subject: [PATCH] feat(images): zoom into the full resolution of a picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer shows a preview sized to the screen, and capped the zoom at five times it, so zooming in only ever enlarged its pixels: a tall comic fitted to the viewer could not be read at all. The first zoom now fetches the largest preview the server renders, decodes it in the background and swaps it in at the size the picture is drawn at, and lets the zoom go until one of its pixels is four on screen, never less than the five times it always allowed. Zooming goes on while it loads. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- __tests__/component/media.spec.ts | 131 +++++++++++++++++++++++++++++- __tests__/mediaPreloader.spec.ts | 54 +++++++++++- __tests__/utils.spec.ts | 16 +++- lib/components/Images.vue | 131 ++++++++++++++++++++++++++++-- lib/services/mediaPreloader.ts | 24 ++++++ lib/utils/previewUtils.ts | 43 +++++++++- 6 files changed, 384 insertions(+), 15 deletions(-) diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts index 4e8582e..c040b55 100644 --- a/__tests__/component/media.spec.ts +++ b/__tests__/component/media.spec.ts @@ -1,3 +1,4 @@ +import type { VueWrapper } from '@vue/test-utils' import type { Mock } from 'vitest' /** * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors @@ -15,6 +16,7 @@ import { makeFile } from '../factories.ts' vi.mock('../../lib/services/mediaPreloader.ts', () => ({ preloadMedia: vi.fn(async () => 'blob:mock-preloaded-media'), preloadPreview: vi.fn(async () => 'blob:mock-preloaded-preview'), + preloadImage: vi.fn(async () => 0), })) // An svg is read and sanitized rather than handed to the element, so the @@ -94,10 +96,11 @@ import Images from '../../lib/components/Images.vue' import Videos from '../../lib/components/Videos.vue' import { usePlyrPlayer } from '../../lib/composables/usePlyrPlayer.ts' import { logger } from '../../lib/services/logger.ts' -import { preloadMedia, preloadPreview } from '../../lib/services/mediaPreloader.ts' +import { preloadImage, preloadMedia, preloadPreview } from '../../lib/services/mediaPreloader.ts' const preloadMediaMock = vi.mocked(preloadMedia) const preloadPreviewMock = vi.mocked(preloadPreview) +const preloadImageMock = vi.mocked(preloadImage) /** * Build the full ViewerProps set with sensible defaults for a mounted media component. @@ -131,6 +134,7 @@ function mountImages(overrides: Partial = {}) { beforeEach(() => { preloadMediaMock.mockClear() preloadPreviewMock.mockClear() + preloadImageMock.mockReset() }) describe('Images.vue', () => { @@ -655,6 +659,131 @@ describe('Audios.vue (smoke)', () => { }) }) +describe('zooming into a picture', () => { + /** + * Mount a picture shown from a 1000 pixel preview that fills the + * viewer, the way a screen-sized preview does. + * + * @param attributes - The file's dav attributes + */ + async function mountPreviewed(attributes: Record = { hasPreview: true }) { + const file = makeFile({ basename: 'comic.jpg', attributes }) + const wrapper = mountImages({ file, files: [file] }) + await flushPromises() + const image = wrapper.find('img').element as HTMLImageElement + Object.defineProperty(image, 'naturalWidth', { value: 1000, configurable: true }) + Object.defineProperty(image, 'naturalHeight', { value: 1000, configurable: true }) + await wrapper.find('img').trigger('load') + return { wrapper, file } + } + + /** + * Zoom in with the wheel, one notch at a time. + * + * @param wrapper - The mounted picture + * @param notches - How many notches + */ + async function zoomIn(wrapper: VueWrapper, notches: number) { + for (let notch = 0; notch < notches; notch++) { + wrapper.find('img').element.dispatchEvent(new WheelEvent('wheel', { deltaY: -1, bubbles: true, cancelable: true })) + await flushPromises() + } + } + + /** The width the picture is drawn at, in pixels */ + const drawnWidth = (wrapper: VueWrapper) => Number.parseInt((wrapper.find('img').element as HTMLImageElement).style.width) + + it('asks for its most detailed preview the first time it is zoomed into, and shows it', async () => { + preloadImageMock.mockResolvedValue(4000) + const { wrapper } = await mountPreviewed() + expect(preloadImageMock).not.toHaveBeenCalled() + + await zoomIn(wrapper, 3) + + expect(preloadImageMock).toHaveBeenCalledOnce() + const [url] = preloadImageMock.mock.calls[0]! + expect(url).toContain('x=8192') + expect(url).toContain('y=8192') + expect(wrapper.find('img').attributes('src')).toBe(url) + }) + + it('stays the size it was drawn at when the sharper one replaces it', async () => { + preloadImageMock.mockResolvedValue(4001) + const { wrapper } = await mountPreviewed() + await zoomIn(wrapper, 3) + const drawn = (wrapper.find('img').element as HTMLImageElement).style.cssText + + // Not quite the same proportions once rounded: refitted from these, + // the picture would come out a pixel shorter under the user's zoom + const image = wrapper.find('img').element as HTMLImageElement + Object.defineProperty(image, 'naturalWidth', { value: 4001, configurable: true }) + Object.defineProperty(image, 'naturalHeight', { value: 3999, configurable: true }) + await wrapper.find('img').trigger('load') + + expect(image.style.cssText).toBe(drawn) + expect(wrapper.emitted('loaded')).toHaveLength(1) + }) + + it('zooms in until a pixel of the picture is four on screen', async () => { + preloadImageMock.mockResolvedValue(4000) + const { wrapper } = await mountPreviewed() + + await zoomIn(wrapper, 40) + + // 4000 pixels across shown 1000 wide: 4 times to reach them, 16 to + // make each of them four + expect(drawnWidth(wrapper)).toBe(16000) + }) + + it('stops at five times for a picture the preview already shows in full', async () => { + // The server renders nothing past the original + preloadImageMock.mockResolvedValue(1000) + const { wrapper } = await mountPreviewed() + const preview = wrapper.find('img').attributes('src') + + await zoomIn(wrapper, 40) + + expect(drawnWidth(wrapper)).toBe(5000) + expect(wrapper.find('img').attributes('src')).toBe(preview) + }) + + it('keeps the picture it has when the sharper one does not come', async () => { + preloadImageMock.mockRejectedValue(new Error('Could not load')) + const { wrapper } = await mountPreviewed() + const preview = wrapper.find('img').attributes('src') + + await zoomIn(wrapper, 40) + + expect(wrapper.find('img').attributes('src')).toBe(preview) + expect(drawnWidth(wrapper)).toBe(5000) + expect(wrapper.emitted('errored')).toBeUndefined() + }) + + it('asks with the preview header on a share that forbids downloading', async () => { + preloadPreviewMock.mockResolvedValue('blob:detail') + preloadImageMock.mockResolvedValue(4000) + const { wrapper } = await mountPreviewed({ + hasPreview: true, + 'share-attributes': JSON.stringify([{ scope: 'permissions', key: 'download', value: false }]), + }) + + await zoomIn(wrapper, 1) + + const [url] = preloadPreviewMock.mock.calls.at(-1)! + expect(url).toContain('x=8192') + expect(preloadImageMock).toHaveBeenCalledWith('blob:detail', expect.any(AbortSignal)) + expect(wrapper.find('img').attributes('src')).toBe('blob:detail') + }) + + it('asks for nothing more when it shows the file itself', async () => { + const { wrapper } = await mountPreviewed({}) + + await zoomIn(wrapper, 3) + + expect(preloadImageMock).not.toHaveBeenCalled() + }) +}) + describe('an image from a share that forbids downloading', () => { const restricted = () => makeFile({ basename: 'restricted.jpg', diff --git a/__tests__/mediaPreloader.spec.ts b/__tests__/mediaPreloader.spec.ts index e34efeb..b9d5846 100644 --- a/__tests__/mediaPreloader.spec.ts +++ b/__tests__/mediaPreloader.spec.ts @@ -2,13 +2,13 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const axiosGet = vi.hoisted(() => vi.fn()) vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet } })) vi.mock('@nextcloud/files/dav', () => ({ getClient: () => ({ getFileContents: vi.fn() }) })) -const { preloadPreview } = await import('../lib/services/mediaPreloader.ts') +const { preloadImage, preloadPreview } = await import('../lib/services/mediaPreloader.ts') describe('preloadPreview', () => { beforeEach(() => { @@ -51,3 +51,53 @@ describe('preloadPreview', () => { await expect(preloadPreview('/core/preview?fileId=1')).rejects.toThrow('forbidden') }) }) + +describe('preloadImage', () => { + let decoded: { resolve: () => void, reject: (error: Error) => void } + + beforeEach(() => { + // jsdom neither loads nor decodes images, and has no decode() at + // all: the test says when it is done + Object.defineProperty(HTMLImageElement.prototype, 'decode', { + configurable: true, + value: () => new Promise((resolve, reject) => { + decoded = { resolve, reject } + }), + }) + vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(4000) + }) + + afterEach(() => { + delete (HTMLImageElement.prototype as { decode?: unknown }).decode + vi.restoreAllMocks() + }) + + it('answers once the image is decoded, not merely loaded', async () => { + const settled = vi.fn() + const loading = preloadImage('/core/preview?fileId=1&x=8192&y=8192').then(settled) + + // Loaded is not enough: a large picture still takes a frame or two to + // decode, and swapped in before that it stalls on screen + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + decoded.resolve() + await loading + expect(settled).toHaveBeenCalledWith(4000) + }) + + it('fails when the image cannot be decoded', async () => { + const loading = preloadImage('/core/preview?fileId=1') + decoded.reject(new Error('EncodingError')) + + await expect(loading).rejects.toThrow('Could not load /core/preview?fileId=1') + }) + + it('gives up when the viewer moves on', async () => { + const controller = new AbortController() + const loading = preloadImage('/core/preview?fileId=1', controller.signal) + controller.abort(new Error('moved on')) + + await expect(loading).rejects.toThrow('moved on') + }) +}) diff --git a/__tests__/utils.spec.ts b/__tests__/utils.spec.ts index 7b1b2eb..776cea2 100644 --- a/__tests__/utils.spec.ts +++ b/__tests__/utils.spec.ts @@ -11,7 +11,7 @@ import { findLivePhotoPeerFromFileId, findLivePhotoPeerFromName, } from '../lib/utils/livePhotoUtils.ts' -import { getPreviewIfAny } from '../lib/utils/previewUtils.ts' +import { getLargestPreview, getPreviewIfAny } from '../lib/utils/previewUtils.ts' import { makeFile } from './factories.ts' // generateUrl echoes the given path so we can assert on the built query string. @@ -129,6 +129,20 @@ describe('previewUtils.getPreviewIfAny', () => { expect(getPreviewIfAny(file)).toBe(file.encodedSource) expect(getPreviewIfAny(file)).not.toContain('#') }) + + // The server caps it at the original and at its own preview limit, so + // this asks for whatever most it will give, whatever the screen is + it('asks for the largest preview past the size of the display', () => { + const url = getLargestPreview(makeFileWithAttributes({ hasPreview: true })) + expect(url).toContain('/core/preview?') + expect(url).toContain('x=8192') + expect(url).toContain('y=8192') + }) + + it('has no larger preview for a file without one, or with one an app chose', () => { + expect(getLargestPreview(makeFileWithAttributes({ hasPreview: false }))).toBeUndefined() + expect(getLargestPreview(makeFileWithAttributes({ previewUrl: '/direct/preview.png', hasPreview: true }))).toBeUndefined() + }) }) describe('livePhotoUtils.findLivePhotoPeerFromFileId', () => { diff --git a/lib/components/Images.vue b/lib/components/Images.vue index a47d999..8e9cd5e 100644 --- a/lib/components/Images.vue +++ b/lib/components/Images.vue @@ -81,11 +81,11 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import PlayCircleOutline from 'vue-material-design-icons/PlayCircleOutline.vue' import { useViewerProps } from '../composables/useViewerProps.ts' import { logger } from '../services/logger.ts' -import { preloadMedia, preloadPreview } from '../services/mediaPreloader.ts' +import { preloadImage, preloadMedia, preloadPreview } from '../services/mediaPreloader.ts' import { canDownload } from '../utils/canDownload.ts' import { t } from '../utils/l10n.ts' import { findLivePhotoPeerFromFileId } from '../utils/livePhotoUtils.ts' -import { getPreviewIfAny } from '../utils/previewUtils.ts' +import { getLargestPreview, getPreviewIfAny } from '../utils/previewUtils.ts' defineOptions({ name: 'ViewerImages', @@ -124,6 +124,36 @@ const loaded = ref(false) const height = ref(0) const width = ref(0) +/** + * How far in a picture zooms, at the least: as far as it always did, + * whatever its resolution. + */ +const MIN_ZOOM_LIMIT = 5 +/** + * How many screen pixels one pixel of the picture may grow to. Past its + * native resolution there is nothing more to see, but text and line art + * are still easier to read a few times bigger. + */ +const PIXEL_ZOOM_LIMIT = 4 + +// Whether what is shown is the server's preview, which a sharper one can +// replace, rather than the file itself or a preview an app chose +const shownFromPreview = ref(false) +// The intrinsic width of the most detailed version of the picture shown +const nativeWidth = ref(0) +// The sharper preview fetched for zooming in, once it is on screen +let detailSource: string | null = null +let detailRequested = false +let detailController: AbortController | null = null + +/** + * The furthest the picture zooms: a few screen pixels for each of its own, + * once its resolution is known, and never less than it always could. + */ +const zoomLimit = computed(() => width.value > 0 + ? Math.max(MIN_ZOOM_LIMIT, PIXEL_ZOOM_LIMIT * nativeWidth.value / width.value) + : MIN_ZOOM_LIMIT) + // Computed properties const mime = computed(() => props.file.mime) @@ -220,9 +250,14 @@ function showOwnedUrl(url: string): void { // two files can be shown under one name and it is the source that says // which bytes to fetch. watch(() => props.file.source, () => { + forgetDetail() load() }) -watch(data, () => { +watch(data, (source) => { + // The same picture, sharper: it is already on screen and loaded + if (source !== null && source === detailSource) { + return + } loaded.value = false catchUpIfLoaded() }) @@ -261,6 +296,7 @@ async function loadData() { const controller = new AbortController() inFlight = { controller, source: props.file.source } const { signal } = controller + shownFromPreview.value = false // A client-side source (e.g. a just-edited image) is shown as-is, no fetch. if (props.localSource) { data.value = props.localSource @@ -283,9 +319,11 @@ async function loadData() { // that may not is refused at the source too, so its preview is the // only thing left to ask for, and only with a header an element // cannot set on its own request. - showOwnedUrl(canDownload(props.file) + const allowed = canDownload(props.file) + showOwnedUrl(allowed ? await preloadMedia(props.file, signal) : await preloadPreview(previewPath.value, signal)) + shownFromPreview.value = !allowed && hasPreview.value && !previewUrl.value return } @@ -302,6 +340,7 @@ async function loadData() { } data.value = previewPath.value + shownFromPreview.value = hasPreview.value && !previewUrl.value } /** @@ -325,9 +364,82 @@ async function catchUpIfLoaded() { */ function onDoneLoading() { loaded.value = true + nativeWidth.value = Math.max(nativeWidth.value, image.value?.naturalWidth ?? 0) + // The same picture, sharper, in the box it already fills: refitted from + // its own size, rounding could move it by a pixel under the user's zoom + if (data.value !== null && data.value === detailSource) { + return + } updateImageSize() } +/** + * Fetch the most detailed preview of the picture, the first time it is + * zoomed into, and show it once it has arrived. + * + * The preview shown first is sized to the screen, so zooming into it only + * ever enlarges its pixels. Fetched on the first zoom rather than with the + * picture: rendering a large preview is work for the server, and most + * pictures are looked at without zooming at all. + */ +async function fetchDetail() { + if (detailRequested || !shownFromPreview.value) { + return + } + const url = getLargestPreview(props.file) + if (url === undefined) { + return + } + detailRequested = true + const controller = new AbortController() + detailController = controller + const { signal } = controller + // An object URL made here and not shown holds its blob until revoked + let unshown: string | null = null + try { + // A share that forbids downloading answers a preview only to the + // header the viewer sends, which an element cannot set itself + const source = canDownload(props.file) ? url : await preloadPreview(url, signal) + unshown = source === url ? null : source + const detailWidth = await preloadImage(source, signal) + if (signal.aborted) { + return + } + // The server never renders past the original, so this is as much + // as there is: the zoom may go that far even when it shows nothing new + nativeWidth.value = Math.max(nativeWidth.value, detailWidth) + if (detailWidth <= (image.value?.naturalWidth ?? 0)) { + return + } + detailSource = source + if (unshown === null) { + data.value = source + } else { + showOwnedUrl(source) + unshown = null + } + } catch (error) { + if (!signal.aborted) { + logger.debug(`Could not fetch a sharper preview of ${filename.value}`, { error }) + } + } finally { + if (unshown !== null) { + URL.revokeObjectURL(unshown) + } + } +} + +/** + * Drop the sharper preview of the file shown before. + */ +function forgetDetail() { + detailController?.abort() + detailController = null + detailSource = null + detailRequested = false + nativeWidth.value = 0 +} + // The viewer hears 'loaded' as a DOM event on the custom element, and an // event dispatched from inside the img's own load handler can land while // Vue is still patching that element — the listener is attached, but not @@ -377,6 +489,7 @@ watch(turns, () => { onUnmounted(() => { inFlight?.controller.abort() + detailController?.abort() if (ownedUrl !== null) { URL.revokeObjectURL(ownedUrl) ownedUrl = null @@ -437,6 +550,9 @@ function updateZoomAndShift(stableX: number, stableY: number, newZoomRatio: numb const newShiftY = shiftY.value - scrollPercY * growY updateShift(newShiftX, newShiftY, newZoomRatio) zoomRatio.value = newZoomRatio + if (newZoomRatio > 1) { + fetchDetail() + } } /** @@ -460,7 +576,7 @@ function distanceBetweenTouches(): number { function updateZoom(event: WheelEvent) { const isZoomIn = event.deltaY < 0 const newZoomRatio = isZoomIn - ? Math.min(zoomRatio.value * 1.1, 5) // prevent too big zoom + ? Math.min(zoomRatio.value * 1.1, zoomLimit.value) : Math.max(zoomRatio.value / 1.1, 1) // prevent too small zoom // do not continue, img is back to its original state @@ -578,8 +694,8 @@ function pointerMove(event: PointerEvent) { // Calculate current distance between touches const newDistance = distanceBetweenTouches() - // Calculate new zoom ratio - keep it between 1 and 5 - const newZoomRatio = Math.min(Math.max(pinchStartZoomRatio.value * (newDistance / pinchDistance.value), 1), 5) + // Calculate new zoom ratio, between the fitted picture and the zoom limit + const newZoomRatio = Math.min(Math.max(pinchStartZoomRatio.value * (newDistance / pinchDistance.value), 1), zoomLimit.value) // Calculate "stable" point - in the middle between touches const t0 = pointerCache.value[0] @@ -602,6 +718,7 @@ function onDblclick() { resetZoom() } else { zoomRatio.value = 1.3 + fetchDetail() } } diff --git a/lib/services/mediaPreloader.ts b/lib/services/mediaPreloader.ts index 1e58599..9135569 100644 --- a/lib/services/mediaPreloader.ts +++ b/lib/services/mediaPreloader.ts @@ -44,3 +44,27 @@ export async function preloadPreview(url: string, signal?: AbortSignal): Promise }) return URL.createObjectURL(response.data as Blob) } + +/** + * Load and decode an image in the background, so an element shown it next + * paints it at once rather than going blank, or stalling a frame or two + * on a large picture, while it arrives. + * + * @param url the image URL to load + * @param signal aborts the load when the viewer moves to another file + * @return the image's intrinsic width + */ +export function preloadImage(url: string, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + signal?.addEventListener('abort', () => { + image.src = '' + reject(signal.reason) + }, { once: true }) + image.src = url + image.decode().then( + () => resolve(image.naturalWidth), + () => reject(new Error(`Could not load ${url}`)), + ) + }) +} diff --git a/lib/utils/previewUtils.ts b/lib/utils/previewUtils.ts index 218ef1f..8394f2e 100644 --- a/lib/utils/previewUtils.ts +++ b/lib/utils/previewUtils.ts @@ -24,6 +24,16 @@ interface AvailableSpace { */ const SIZE_STEP = 256 +/** + * The size asked for when the user zooms in for detail. + * + * The server renders no preview larger than the original, nor than its own + * `preview_max_x`/`preview_max_y` (4096 by default), so this gets the most + * detail it will give. The ceiling is for a server allowed more, as a + * bitmap past this many pixels is more than a browser handles comfortably. + */ +const LARGEST_PREVIEW = 8192 + /** * The pixel size to ask a preview for. * @@ -64,6 +74,34 @@ export function getPreviewIfAny(file: IFile, available?: AvailableSpace): string } const { x, y } = previewSize(available) + // Encoded: this is handed to a media element as its `src`, and a name + // holding a `#` or a `?` would otherwise cut the URL short. + return serverPreview(file, x, y) ?? file.encodedSource +} + +/** + * The most detailed preview the server will render of a file, to zoom into. + * + * Nothing for a file without a server preview, or with one an app chose + * (`previewUrl`), whose size is not ours to change. + * + * @param file - The file to resolve a preview URL for + */ +export function getLargestPreview(file: IFile): string | undefined { + if (file.attributes.previewUrl) { + return undefined + } + return serverPreview(file, LARGEST_PREVIEW, LARGEST_PREVIEW) +} + +/** + * The URL of a server preview of a file, at most this many pixels on each side. + * + * @param file - The file to resolve a preview URL for + * @param x - The most pixels wide + * @param y - The most pixels high + */ +function serverPreview(file: IFile, x: number, y: number): string | undefined { const searchParams = `fileId=${file.fileid}` + `&x=${x}` + `&y=${y}` @@ -81,8 +119,5 @@ export function getPreviewIfAny(file: IFile, available?: AvailableSpace): string } return generateUrl(`/core/preview?${searchParams}`) } - - // Encoded: this is handed to a media element as its `src`, and a name - // holding a `#` or a `?` would otherwise cut the URL short. - return file.encodedSource + return undefined }