Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<nc-viewer-file>`, outside the viewer: start read-only, no autoplay. You may offer your own switch to editing there. Optional |

`ViewerEmits` lets you emit:

Expand Down Expand Up @@ -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 `<nc-viewer-file>` there, give it the file and a size:

```vue
<nc-viewer-file :file.prop="file" style="width: 280px; height: 180px" />
```

```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
Expand Down Expand Up @@ -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` |
Expand Down
20 changes: 20 additions & 0 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
223 changes: 223 additions & 0 deletions __tests__/component/viewerFile.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<nc-viewer-file>` 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')
})
})
35 changes: 35 additions & 0 deletions __tests__/defaultHandlers.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})

// <nc-viewer-file> 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()
})
})
Loading
Loading