From 86c3862b6053a757814ffae6a6aa56d088e61a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:41:39 +0200 Subject: [PATCH 1/6] feat(viewer): name the element that shows a file, to render it elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview of a file link (the files reference widget, in Talk, Text and the like) used to render the viewer handler's component inline, found through OCA.Viewer.availableHandlers. Handlers are custom elements now, and the registry is not something a page should dig through, so getViewer().elementFor(file) names the element of the handler that would open the file, defined and ready to render, without loading the viewer. `embedded` on ViewerProps tells the handler it is shown inline, which the old widget said with `isEmbedded` and `active`. A hint not to take over the page: videos and sounds wait to be played, and editing is better left to the viewer. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- README.md | 31 +++++++++++++++++++++++- __tests__/component/media.spec.ts | 20 ++++++++++++++++ __tests__/viewerService.spec.ts | 39 ++++++++++++++++++++++++++++++- lib/components/Audios.vue | 2 +- lib/components/Videos.vue | 2 +- lib/viewer.ts | 34 +++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4bf42e8..0911cf7 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,35 @@ 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, ask the viewer which element shows it and render that element +yourself: + +```ts +import { getViewer } from '@nextcloud/viewer' + +const tagName = await getViewer().elementFor(file) +if (tagName !== undefined) { + const element = document.createElement(tagName) + Object.assign(element, { file, files: [file], maxWidth: 600, maxHeight: 400, embedded: true }) + element.addEventListener('loaded', () => { /* shown */ }) + element.addEventListener('errored', () => { /* fall back to a plain link */ }) + container.append(element) +} +``` + +It names the element of the handler the viewer would open the file with, once +that element is defined (a handler with `onInit()` is initialized first), and +nothing when no handler takes the file or the user cannot read it. The element +takes the props and emits the events of any handler (`ViewerProps`, +`ViewerEmits`). Nothing of the viewer itself is loaded for it. + +Set `embedded` so the handler knows it is shown inline: videos and sounds then +wait to be played rather than starting on their own. Editing is usually better +left to the viewer: offer a way to open the file there with `getViewer().open()`. + ### 🕰️ Nextcloud 35 and older The library is made for Nextcloud 36, whose server ships a copy of it. Older @@ -561,7 +590,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__/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/lib/components/Audios.vue b/lib/components/Audios.vue index 19c2921..03e6948 100644 --- a/lib/components/Audios.vue +++ b/lib/components/Audios.vue @@ -10,7 +10,7 @@