diff --git a/README.md b/README.md index 4bf42e8..620a49e 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ const src = computed(() => props.file.encodedSource) | `localSource` | `string` | An object URL to show instead of the file, e.g. right after an edit. Optional | | `turns` | `number` | Quarter turns the viewer asks you to show the file rotated by. Optional | | `comparison` | `'side-by-side' \| 'differences'` | How the viewer compares `files`, see [comparing two files](#comparing-two-files). Optional | +| `embedded` | `boolean` | Shown inline by a ``, outside the viewer: start read-only, no autoplay. You may offer your own switch to editing there. Optional | `ViewerEmits` lets you emit: @@ -522,6 +523,36 @@ opens on it. The view chosen is kept until the viewer closes. `compare()` when viewer is already open compares the new files in it. Their elements are mounted again, as for every file opened. +### 🖼️ Show a file outside the viewer + +To show a file where it is mentioned, as the preview of a file link in a chat +or a document, put a `` there, give it the file and a size: + +```vue + +``` + +```ts +const element = document.createElement('nc-viewer-file') +element.file = file +container.append(element) +``` + +It shows the file with the handler the viewer would open it with, fitted to +its box, and loads nothing of the viewer itself. While the file loads it +shows its blurhash where the server has one. Over the file, a strip names it +and offers to open it in the viewer or to download it, on hover, focus or a +tap. A video waits behind its poster until played, and a sound waits too. + +When nothing can show the file, or it fails to, it says so and offers the +download where the share allows it. Put something inside the element, such as +a link of your own, to show that instead. It dispatches `loaded` and +`errored`, as a handler does. + +To render the handler's element yourself, `getViewer().elementFor(file)` +names it, once it is defined, or nothing when no handler takes the file. +Give it the props of any handler (`ViewerProps`), with `embedded: true`. + ### 🕰️ Nextcloud 35 and older The library is made for Nextcloud 36, whose server ships a copy of it. Older @@ -561,7 +592,7 @@ instead, and the viewer works with `@nextcloud/files` nodes rather than the | `OCA.Viewer.close()` | `getViewer().close()` | | `OCA.Viewer.mimetypes.includes(node.mime)` | `canView(node)` | | `OCA.Viewer.mimetypesCompare.includes(node.mime)` | `canView(node)` | -| `OCA.Viewer.availableHandlers` | `getHandlers()`, or `canView(node)` to test one file | +| `OCA.Viewer.availableHandlers` | `getHandlers()`, `canView(node)` to test one file, `getViewer().elementFor(node)` to render it | | `OCA.Viewer.registerHandler({ component })` | `registerHandler({ tagName })`, see above | | `canCompare: true` on a handler | `canCompare: (nodes) => true`, and `canCompare(node)` to decide whether to offer it | | `\OCP\Util::addScript` for the registration | `\OCP\Util::addInitScript` | diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts index 204be8d..9ed0fa7 100644 --- a/__tests__/component/media.spec.ts +++ b/__tests__/component/media.spec.ts @@ -555,6 +555,26 @@ describe('the keyboard, once the media is on screen', () => { }) }) +// Shown inline, as the preview of a link in a chat, the media would start +// playing in the middle of the page +describe.each([ + ['video', 'clip.mp4', 'video/mp4'], + ['sound', 'song.mp3', 'audio/mpeg'], +] as const)('a %s', (kind, basename, mime) => { + const component = kind === 'video' ? Videos : Audios + + it.each([ + ['plays on its own in the viewer', false, true], + ['waits to be played when shown inline', true, false], + ])('%s', async (_name, embedded, autoplay) => { + const file = makeFile({ basename, mime }) + const wrapper = mount(component, { props: makeProps({ file, files: [file], embedded }) }) + await flushPromises() + + expect((wrapper.find(kind === 'video' ? 'video' : 'audio').element as HTMLMediaElement).autoplay).toBe(autoplay) + }) +}) + describe('a video that has played to the end', () => { // What a user puts beside a film to show before and after it plays: // a picture of the same name in the same folder diff --git a/__tests__/component/viewerFile.spec.ts b/__tests__/component/viewerFile.spec.ts new file mode 100644 index 0000000..c08a11f --- /dev/null +++ b/__tests__/component/viewerFile.spec.ts @@ -0,0 +1,223 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFile } from '@nextcloud/files' + +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// What the viewer answers: the element that shows a file, and opening it +const viewer = vi.hoisted(() => ({ elementFor: vi.fn(), open: vi.fn() })) +vi.mock('../../lib/viewer.ts', () => ({ getViewer: () => viewer })) + +const { default: ViewerFile } = await import('../../lib/components/ViewerFile.vue') +const { makeFile } = await import('../factories.ts') + +// A handler's element, keeping what it is given to read it back +class ShownElement extends HTMLElement { + file?: IFile + files?: IFile[] + embedded?: boolean + shown?: boolean + maxWidth?: number +} +customElements.define('oca-viewer-embedded', ShownElement) + +afterEach(() => { + viewer.elementFor.mockReset() + viewer.open.mockReset() + document.body.innerHTML = '' +}) + +/** + * Show a file as `` does. + * + * @param file - The file + * @param tagName - The element the viewer names for it, none if nothing shows it + * @param fallback - What the page put inside the element + */ +async function show(file: IFile, tagName?: string, fallback: Node[] = []) { + viewer.elementFor.mockResolvedValue(tagName) + const host = document.createElement('div') + const wrapper = mount(ViewerFile, { props: { file, host, fallback }, attachTo: document.body }) + await flushPromises() + const element = wrapper.find('oca-viewer-embedded').exists() + ? wrapper.find('oca-viewer-embedded').element as ShownElement + : undefined + return { wrapper, host, element } +} + +describe('a file shown where it is mentioned', () => { + it('is shown by the element of its handler, inline', async () => { + const file = makeFile({ basename: 'beach.jpg' }) + const { element } = await show(file, 'oca-viewer-embedded') + + expect(viewer.elementFor).toHaveBeenCalledWith(file) + expect(element!.file?.source).toBe(file.source) + expect(element!.files?.map((shown) => shown.source)).toEqual([file.source]) + // Videos and sounds wait to be played + expect(element!.embedded).toBe(true) + expect(element!.shown).toBe(false) + }) + + it('is said to have loaded once its handler has', async () => { + const { wrapper, host, element } = await show(makeFile({ basename: 'beach.jpg' }), 'oca-viewer-embedded') + const loaded = vi.fn() + host.addEventListener('loaded', loaded) + expect(wrapper.find('.viewer-file__bar').exists()).toBe(false) + + element!.dispatchEvent(new CustomEvent('loaded')) + await flushPromises() + + expect(loaded).toHaveBeenCalledOnce() + expect(element!.shown).toBe(true) + expect(wrapper.find('.viewer-file__name').text()).toBe('beach.jpg') + }) + + it('opens in the viewer from its menu', async () => { + const file = makeFile({ basename: 'beach.jpg' }) + const { wrapper, element } = await show(file, 'oca-viewer-embedded') + element!.dispatchEvent(new CustomEvent('loaded')) + await flushPromises() + + await wrapper.find('.viewer-file__actions button').trigger('click') + await flushPromises() + const open = wrapper.findAllComponents({ name: 'NcActionButton' }).find((action) => action.text().includes('Open in the viewer')) + await open!.vm.$emit('click') + + expect(viewer.open).toHaveBeenCalledOnce() + const [files, shown] = viewer.open.mock.calls[0]! + expect(files.map((opened: IFile) => opened.source)).toEqual([file.source]) + expect(shown.source).toBe(file.source) + }) +}) + +// The handler's whole player over a small box is a lot: a video waits behind +// its poster and one play button until played +describe('a video shown where it is mentioned', () => { + /** + * Show a video, its handler's element holding a video with that poster. + * + * @param poster - The poster, none for a video without + */ + async function showVideo(poster?: string) { + const shown = await show(makeFile({ basename: 'trailer.webm', mime: 'video/webm' }), 'oca-viewer-embedded') + const video = document.createElement('video') + if (poster) { + video.poster = poster + } + video.play = vi.fn(async () => {}) + shown.element!.append(video) + shown.element!.dispatchEvent(new CustomEvent('loaded')) + await flushPromises() + return { ...shown, video } + } + + it('waits behind its poster', async () => { + const { wrapper } = await showVideo('https://cloud.example.com/poster.jpg') + const cover = wrapper.find('.viewer-file__cover') + + expect(cover.attributes('aria-label')).toBe('Play') + expect(cover.attributes('style')).toContain('poster.jpg') + }) + + it('plays, and leaves its player to it, once the cover is clicked', async () => { + const { wrapper, video } = await showVideo() + + await wrapper.find('.viewer-file__cover').trigger('click') + + expect(video.play).toHaveBeenCalledOnce() + expect(wrapper.find('.viewer-file__cover').exists()).toBe(false) + }) + + it('is no cover over a picture', async () => { + const { wrapper, element } = await show(makeFile({ basename: 'beach.jpg' }), 'oca-viewer-embedded') + element!.dispatchEvent(new CustomEvent('loaded')) + await flushPromises() + + expect(wrapper.find('.viewer-file__cover').exists()).toBe(false) + }) +}) + +describe('a file nothing shows', () => { + it('says so, with its name, its size and the download', async () => { + const { wrapper } = await show(makeFile({ basename: 'report.zip', mime: 'application/zip', size: 4404019 })) + + expect(wrapper.find('.viewer-file__type').text()).toBe('ZIP') + expect(wrapper.find('.viewer-file__card-name').text()).toBe('report.zip') + expect(wrapper.find('.viewer-file__card-meta').text()).toBe('4.2 MB · No preview available') + expect(wrapper.find('button[aria-label="Download"]').exists()).toBe(true) + }) + + it('offers no download where its share forbids it', async () => { + const file = makeFile({ basename: 'report.zip', mime: 'application/zip', attributes: { 'hide-download': true } }) + const { wrapper } = await show(file) + + expect(wrapper.find('.viewer-file__card').exists()).toBe(true) + expect(wrapper.find('button[aria-label="Download"]').exists()).toBe(false) + }) + + it('shows what the page put inside instead', async () => { + const link = document.createElement('a') + link.textContent = 'report.zip' + const { wrapper } = await show(makeFile({ basename: 'report.zip', mime: 'application/zip' }), undefined, [link]) + + expect(wrapper.find('.viewer-file__card').exists()).toBe(false) + expect(wrapper.find('.viewer-file__fallback').element.contains(link)).toBe(true) + }) + + it('says so as well when its handler fails to show it', async () => { + const { wrapper, host, element } = await show(makeFile({ basename: 'beach.jpg' }), 'oca-viewer-embedded') + const errored = vi.fn() + host.addEventListener('errored', errored) + + element!.dispatchEvent(new CustomEvent('errored', { detail: [new Error('broken')] })) + await flushPromises() + + expect(errored).toHaveBeenCalledOnce() + expect(wrapper.find('.viewer-file__card-name').text()).toBe('beach.jpg') + }) +}) + +describe('another file given', () => { + it('is asked for, and a late answer for the one before is dropped', async () => { + let answerFirst!: (tag: string | undefined) => void + viewer.elementFor.mockReturnValueOnce(new Promise((resolve) => { + answerFirst = resolve + })) + const host = document.createElement('div') + const first = makeFile({ basename: 'first.jpg' }) + const wrapper = mount(ViewerFile, { props: { file: first, host, fallback: [] } }) + + viewer.elementFor.mockResolvedValueOnce(undefined) + await wrapper.setProps({ file: makeFile({ basename: 'second.zip', mime: 'application/zip' }) }) + await flushPromises() + answerFirst('oca-viewer-embedded') + await flushPromises() + + expect(wrapper.find('oca-viewer-embedded').exists()).toBe(false) + expect(wrapper.find('.viewer-file__card-name').text()).toBe('second.zip') + }) +}) + +// Touch has no hover: a tap on the file shows its name and menu +describe('a tap on the file', () => { + it('shows its strip, and the next one hides it', async () => { + const { wrapper } = await show(makeFile({ basename: 'beach.jpg' }), 'oca-viewer-embedded') + const root = wrapper.find('.viewer-file') + + await root.trigger('pointerdown', { pointerType: 'touch' }) + expect(root.classes()).toContain('viewer-file--active') + await root.trigger('pointerdown', { pointerType: 'touch' }) + expect(root.classes()).not.toContain('viewer-file--active') + }) + + it('is not a mouse click', async () => { + const { wrapper } = await show(makeFile({ basename: 'beach.jpg' }), 'oca-viewer-embedded') + const root = wrapper.find('.viewer-file') + + await root.trigger('pointerdown', { pointerType: 'mouse' }) + expect(root.classes()).not.toContain('viewer-file--active') + }) +}) diff --git a/__tests__/defaultHandlers.spec.ts b/__tests__/defaultHandlers.spec.ts new file mode 100644 index 0000000..9a72dab --- /dev/null +++ b/__tests__/defaultHandlers.spec.ts @@ -0,0 +1,35 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { getHandlers } from '../lib/handlers.ts' +import { registerAudioHandler } from '../lib/models/audios.ts' +import { registerImageHandler } from '../lib/models/images.ts' +import { registerSheetmusicHandler } from '../lib/models/sheetmusic.ts' +import { registerVideoHandler } from '../lib/models/videos.ts' +import { getViewer } from '../lib/viewer.ts' +import { makeFile } from './factories.ts' + +// Each test starts on an empty registry +beforeEach(() => { + registerAudioHandler() + registerVideoHandler() + registerImageHandler() + registerSheetmusicHandler() +}) + +// shows them outside the viewer, which defined their +// elements only as it mounted +describe('the default handlers', () => { + it.each(['images', 'videos', 'audios', 'sheetmusic'])('%s define their element when first needed', (id) => { + expect(getHandlers().get(id)?.onInit).toBeTypeOf('function') + }) + + it('have their element defined before it is named', async () => { + expect(customElements.get('oca-viewer-image')).toBeUndefined() + + expect(await getViewer().elementFor(makeFile({ mime: 'image/jpeg' }))).toBe('oca-viewer-image') + expect(customElements.get('oca-viewer-image')).toBeDefined() + }) +}) diff --git a/__tests__/viewerFileElement.spec.ts b/__tests__/viewerFileElement.spec.ts new file mode 100644 index 0000000..beaa648 --- /dev/null +++ b/__tests__/viewerFileElement.spec.ts @@ -0,0 +1,71 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { flushPromises } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// The view, which the element only fetches once it is on the page +const view = vi.hoisted(() => ({ update: vi.fn(), unmount: vi.fn() })) +const mountViewerFile = vi.hoisted(() => vi.fn(async () => view)) +vi.mock('../lib/elements/mountViewerFile.ts', () => ({ mountViewerFile })) + +const { defineViewerFileElement } = await import('../lib/elements/viewerFile.ts') +const { makeFile } = await import('./factories.ts') +defineViewerFileElement() + +afterEach(() => { + document.body.innerHTML = '' + vi.clearAllMocks() +}) + +describe('', () => { + it('fetches nothing until it is on the page', () => { + document.createElement('nc-viewer-file').file = makeFile() + expect(mountViewerFile).not.toHaveBeenCalled() + }) + + it('shows its file, and the next one it is given', async () => { + const element = document.createElement('nc-viewer-file') + const first = makeFile({ basename: 'first.jpg' }) + element.file = first + document.body.append(element) + await flushPromises() + expect(mountViewerFile).toHaveBeenCalledWith(element, first, []) + + const second = makeFile({ basename: 'second.jpg' }) + element.file = second + expect(view.update).toHaveBeenLastCalledWith(second) + }) + + // Shown when nothing can show the file + it('keeps what the page put inside for its view', async () => { + const element = document.createElement('nc-viewer-file') + const link = document.createElement('a') + const text = document.createTextNode(' and more') + element.append(link, text) + document.body.append(element) + await flushPromises() + + expect(mountViewerFile).toHaveBeenCalledWith(element, undefined, [link, text]) + expect(element.contains(link)).toBe(false) + }) + + it('takes its view down once off the page', async () => { + const element = document.createElement('nc-viewer-file') + document.body.append(element) + await flushPromises() + + element.remove() + expect(view.unmount).toHaveBeenCalledOnce() + }) + + it('drops a view that arrives once it is off the page already', async () => { + const element = document.createElement('nc-viewer-file') + document.body.append(element) + element.remove() + await flushPromises() + + expect(view.unmount).toHaveBeenCalledOnce() + }) +}) diff --git a/__tests__/viewerService.spec.ts b/__tests__/viewerService.spec.ts index 1b21eb0..b8aac18 100644 --- a/__tests__/viewerService.spec.ts +++ b/__tests__/viewerService.spec.ts @@ -4,10 +4,12 @@ */ import type ViewerVue from '../lib/views/Viewer.vue' +import { Permission } from '@nextcloud/files' import { describe, expect, it, vi } from 'vitest' +import { registerHandler } from '../lib/handlers.ts' import { registerImplementation, scope } from '../lib/scope.ts' import { getViewer, Viewer } from '../lib/viewer.ts' -import { makeFile } from './factories.ts' +import { makeFile, makeHandler } from './factories.ts' type Mounted = InstanceType @@ -146,3 +148,38 @@ describe('the synchronous methods', () => { expect(mounted.setEditing).toHaveBeenCalledWith(true) }) }) + +describe('elementFor()', () => { + let count = 0 + const tag = () => `oca-viewer-embed-${++count}` + + // A file link's preview shows the file where the link is, without the + // modal, and must not pull the whole viewer in for it + it('names the element of the handler that takes the file, without loading the viewer', async () => { + const load = offerImplementation(makeMounted()) + const tagName = tag() + registerHandler(makeHandler({ id: tagName, tagName, enabled: (nodes) => nodes.every((node) => node.mime === 'image/jpeg') })) + + expect(await getViewer().elementFor(makeFile({ mime: 'image/jpeg' }))).toBe(tagName) + expect(load).not.toHaveBeenCalled() + }) + + it('has the element defined first, by the handler\'s onInit', async () => { + const tagName = tag() + const onInit = vi.fn(async () => { + customElements.define(tagName, class extends HTMLElement {}) + }) + registerHandler(makeHandler({ id: tagName, tagName, enabled: () => true, onInit })) + + expect(await getViewer().elementFor(makeFile())).toBe(tagName) + expect(customElements.get(tagName)).toBeDefined() + }) + + it('names nothing for a file no handler takes, or one the user cannot read', async () => { + const tagName = tag() + registerHandler(makeHandler({ id: tagName, tagName, enabled: (nodes) => nodes.every((node) => node.mime === 'image/jpeg') })) + + expect(await getViewer().elementFor(makeFile({ mime: 'video/mp4' }))).toBeUndefined() + expect(await getViewer().elementFor(makeFile({ mime: 'image/jpeg', permissions: Permission.NONE }))).toBeUndefined() + }) +}) diff --git a/build/check-bundle-size.mjs b/build/check-bundle-size.mjs index 6457f82..21e7952 100644 --- a/build/check-bundle-size.mjs +++ b/build/check-bundle-size.mjs @@ -22,11 +22,15 @@ import { gzipSync } from 'node:zlib' * in it. */ -/** What the entry may cost, gzipped, before anyone has opened a file */ -const BUDGET_GZIP = 10 * 1024 +/** + * What the entry may cost, gzipped, before anyone has opened a file. Raised + * from 10 kB for , defined on every page, and the default + * handlers defining their elements when first needed + */ +const BUDGET_GZIP = 11 * 1024 /** Chunks that must only ever be reached through a dynamic import */ -const MUST_BE_LAZY = ['mount', 'Images', 'Videos', 'Audios', 'ImageEditor', 'usePlyrPlayer', 'translations'] +const MUST_BE_LAZY = ['mount', 'mountViewerFile', 'Images', 'Videos', 'Audios', 'ImageEditor', 'usePlyrPlayer', 'translations'] /** * Packages a page must not pay for unless it opens something needing them. diff --git a/e2e/embeds.spec.ts b/e2e/embeds.spec.ts new file mode 100644 index 0000000..d974875 --- /dev/null +++ b/e2e/embeds.spec.ts @@ -0,0 +1,63 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { expect, test } from '@playwright/test' +import { ViewerPage } from './support/viewer.ts' + +// Files shown where they are mentioned, as the preview of a link in a chat +test.describe('', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?embeds') + }) + + test('shows a picture, and names it under the pointer', async ({ page }) => { + const element = page.locator('nc-viewer-file').nth(0) + await element.scrollIntoViewIfNeeded() + await expect(element.locator('oca-viewer-image img')).toBeVisible() + const bar = element.locator('.viewer-file__bar') + await expect(bar).toHaveCSS('opacity', '0') + + await element.hover() + await expect(bar).toHaveCSS('opacity', '1') + await expect(bar).toContainText('photo.jpg') + }) + + test('opens it in the viewer from its menu', async ({ page }) => { + const element = page.locator('nc-viewer-file').nth(0) + await element.scrollIntoViewIfNeeded() + await expect(element.locator('oca-viewer-image img')).toBeVisible() + await element.hover() + await element.locator('.viewer-file__actions button').first().click() + await page.getByRole('menuitem', { name: 'Open in the viewer' }).click() + + const viewer = new ViewerPage(page) + await viewer.waitForOpen() + expect(await viewer.currentName()).toBe('photo.jpg') + }) + + test('keeps a video behind one play button until played', async ({ page }) => { + const element = page.locator('nc-viewer-file').nth(1) + await element.scrollIntoViewIfNeeded() + const cover = element.getByRole('button', { name: 'Play' }).and(element.locator('.viewer-file__cover')) + await expect(cover).toBeVisible() + + await cover.click() + await expect(cover).toHaveCount(0) + }) + + test('says when nothing can show a file, and offers it to download', async ({ page }) => { + const card = page.locator('.viewer-file__card') + await card.scrollIntoViewIfNeeded() + await expect(card).toContainText('report.zip') + await expect(card).toContainText('No preview available') + await expect(card.getByRole('button', { name: 'Download' })).toBeVisible() + }) + + test('shows what the page put inside instead, when it put something', async ({ page }) => { + const own = page.locator('.playground__embed--own') + await own.scrollIntoViewIfNeeded() + await expect(own.getByRole('link', { name: 'report.zip' })).toBeVisible() + await expect(own.locator('.viewer-file__card')).toHaveCount(0) + }) +}) diff --git a/lib/components/Audios.vue b/lib/components/Audios.vue index 19c2921..83264fc 100644 --- a/lib/components/Audios.vue +++ b/lib/components/Audios.vue @@ -7,10 +7,10 @@ - +