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 }