Skip to content
Merged
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
246 changes: 204 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,171 @@ npm install @nextcloud/viewer

This package covers two independent needs. Most apps only have one:

- **Rendering your own file type in the viewer** — [register a handler](#-add-your-own-file-view)
- **Rendering your own file type in the viewer:** [register a handler](#-add-your-own-file-view)
so files of your mimetype open in the viewer instead of downloading.
- **Opening the viewer from your own code** — [call `getViewer()`](#-open-the-viewer-programmatically),
- **Opening the viewer from your own code:** [call `getViewer()`](#-open-the-viewer-programmatically),
e.g. to open an image from a dashboard widget or a search result.

Registering a handler does not require you to also open the viewer yourself, and
opening the viewer does not require registering a handler.

New to it? The [tutorial](#-tutorial-your-first-handler) builds a small handler
from scratch, the sections after it are the reference.

### 🎓 Tutorial: your first handler

Let's show a file type the viewer doesn't know yet. Say your app `myapp` stores
notes as plain text under their own mime, `application/x-myapp-note`, and clicking
one in Files currently just downloads it. By the end of this, it opens in the
viewer, with next and previous, the sidebar and the header actions for free 👋

#### 1. Install the package

```sh
npm install @nextcloud/viewer @nextcloud/files
```

#### 2. Write the view

A normal Vue component. It gets the file as a prop, and tells the viewer when it
is ready, or when it is not:

```vue
<!-- src/views/NoteView.vue -->
<template>
<pre>{{ text }}</pre>
</template>

<script setup lang="ts">
import type { ViewerEmits, ViewerProps } from '@nextcloud/viewer'

import axios from '@nextcloud/axios'
import { onMounted, ref } from 'vue'

const props = defineProps<ViewerProps>()
const emit = defineEmits<ViewerEmits>()

const text = ref('')

onMounted(async () => {
try {
const { data } = await axios.get<string>(props.file.encodedSource, { responseType: 'text' })
text.value = data
emit('loaded')
} catch (error) {
emit('errored', error as Error)
}
})
</script>
```

Until `loaded` arrives the viewer shows its spinner, so don't forget it. The
element is mounted again for every file, which is why `onMounted` is enough here.

#### 3. Register it

This script turns the component into a custom element and tells the viewer which
files it takes:

```ts
// src/init-viewer.ts
import { t } from '@nextcloud/l10n'
import { registerHandler } from '@nextcloud/viewer'
import { defineCustomElement } from 'vue'
import NoteView from './views/NoteView.vue'

const tagname = 'myapp-note-view'

if (!window.customElements.get(tagname)) {
window.customElements.define(tagname, defineCustomElement(NoteView, { shadowRoot: false }))
}

registerHandler({
id: 'myapp-notes',
displayName: t('myapp', 'Notes'),
tagname,
enabled: (nodes) => nodes.every((node) => node.mime === 'application/x-myapp-note'),
})
```

Add it as an entry of your build, next to your other ones:

```js
// vite.config.js
import { createAppConfig } from '@nextcloud/vite-config'

export default createAppConfig({
'init-viewer': 'src/init-viewer.ts',
})
```

It lands in `js/myapp-init-viewer.mjs`. This script runs on every page, so keep
it small: if your view pulls in something heavy, have the element render a small
wrapper that loads the real view with `defineAsyncComponent`.

#### 4. Load it on every page

Files can be opened from any page, not only the Files app, so the registration
goes out with every page the server renders:

```php
// lib/Listener/ViewerListener.php
namespace OCA\MyApp\Listener;

use OCA\MyApp\AppInfo\Application;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;

/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class ViewerListener implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof BeforeTemplateRenderedEvent) {
return;
}
Util::addInitScript(Application::APP_ID, 'myapp-init-viewer');
}
}
```

```php
// lib/AppInfo/Application.php, in register()
$context->registerEventListener(BeforeTemplateRenderedEvent::class, ViewerListener::class);
```

An init script, not a regular one: the viewer reads its handlers when the first
file is opened, and a handler registered after that never shows up.

#### 5. Try it 🎉

Build, reload Files and click one of your notes. It should open in the viewer.
If it downloads instead, check that `js/myapp-init-viewer.mjs` is on the page and
that your file really has the mime your `enabled()` expects.

#### ✨ Opening it yourself

Your app can open the viewer too, say from a list of recent notes. It takes
`@nextcloud/files` nodes, and the most reliable way to get one is to ask WebDAV,
so it carries everything the viewer looks at, permissions included:

```ts
import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav'
import { getViewer } from '@nextcloud/viewer'

async function openNote(path: string) {
const { data } = await getClient().stat(`${getRootPath()}${path}`, { details: true, data: getDefaultPropfind() })
const node = resultToNode(data)
await getViewer().open([node], node)
}
```

That's it! The sections below go through every option in detail.

### 🔍 Add your own file view

If you want to make your app compatible with this app, you can register your own
handler with the methods provided by the
[`@nextcloud/viewer`](https://www.npmjs.com/package/@nextcloud/viewer) npm package.
To show your own file type in the viewer, register a handler with the
[`@nextcloud/viewer`](https://www.npmjs.com/package/@nextcloud/viewer) package.

Handlers are rendered as **native custom elements**, not as Vue components passed
directly to the viewer. You register a custom element with the browser, then
Expand Down Expand Up @@ -81,6 +233,8 @@ const src = computed(() => props.file.encodedSource)
| `maxWidth` | `number` | Max width of the viewer container |
| `editing` | `boolean` | Whether the viewer is in editing mode |
| `isSidebarShown` | `boolean` | Whether the sidebar is shown |
| `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 |

`ViewerEmits` lets you emit:

Expand Down Expand Up @@ -110,9 +264,11 @@ import MyView from './MyView.vue'
const tagname = 'my-app-viewer'

// Define the custom element. `shadowRoot: false` keeps the element in the
// light DOM so Nextcloud's global styles and CSS variables apply.
const MyElement = defineCustomElement(MyView, { shadowRoot: false })
window.customElements.define(tagname, MyElement)
// light DOM so Nextcloud's global styles and CSS variables apply. A tag can
// only be defined once per page, hence the check.
if (!window.customElements.get(tagname)) {
window.customElements.define(tagname, defineCustomElement(MyView, { shadowRoot: false }))
}

// Register the handler.
registerHandler({
Expand Down Expand Up @@ -171,15 +327,15 @@ Gotchas:

- `tagname` must be lowercase, contain a hyphen, and have no leading, trailing
or consecutive hyphens (e.g. `my-app-viewer`). An invalid one throws.
- `id` must be unique **across every app on the page**, not just your own —
- `id` must be unique **across every app on the page**, not just your own:
it is not namespaced for you. A collision does not throw: the second
registration is silently dropped with a console warning, so pick something
specific to your app (`myapp-image`, not `image`).
- Registering the same handler again, with the same `id` and `tagname`, is
quietly ignored. That is what happens when several copies of the package
on a page register the defaults, so there is nothing to guard against.
- Registering after the viewer has already read the handler list is not an
error either — the handler just never appears in the "Open with …" menu.
error either. The handler just never appears in the "Open with …" menu.
See [step 3](#3-load-your-registration-before-the-viewer) below for why
that means an init script.

Expand All @@ -193,29 +349,24 @@ early enough:
\OCP\Util::addInitScript('myapp', 'myapp-viewer-register');
```

### 📦 Getting the viewer onto the page

Nothing, beyond loading your own registration script. There is no event to
dispatch and no viewer script to add: the copy of the library your app bundles
offers itself as the page's viewer, and whichever copy wins is loaded the first
time a file is opened.

```php
\OCP\Util::addInitScript('myapp', 'myapp-viewer-register');
```

That is all the server side there is. There is no event to dispatch and no viewer
script to add: the copy of the library your app bundles offers itself as the
page's viewer, and whichever copy wins is loaded the first time a file is opened.
See [how a page ends up with one viewer](#-how-a-page-ends-up-with-one-viewer)
for what happens between those two sentences.
for what happens in between.

### 🚀 Open the viewer programmatically

If you are not registering a handler, no server-side setup is needed. A plain
`import { getViewer } from '@nextcloud/viewer'` in your regular bundle is enough —
no `\OCP\Util::addInitScript` required, the server always ships a copy of its own
and registers the handlers for images, video and audio on every page.
`import { getViewer } from '@nextcloud/viewer'` in your regular bundle is enough,
no `\OCP\Util::addInitScript` required: from Nextcloud 36 on, the server ships a
copy of its own and registers the handlers for images, video and audio on every
page.

Importing the package registers nothing by itself. Only a page the server does
not set up, such as a standalone playground, needs to ask for those handlers:
Importing the package registers nothing by itself. A page the server does not set
up, such as a standalone playground, needs to ask for those handlers, and so does
an app that also runs on Nextcloud 35 or older. Calling it where the server
already has is harmless:

```ts
import { registerDefaultHandlers } from '@nextcloud/viewer'
Expand All @@ -224,8 +375,8 @@ registerDefaultHandlers()
```

Only call `open()` in response to an actual user interaction, not eagerly at
import or mount time — see [how a page ends up with one
viewer](#-how-a-page-ends-up-with-one-viewer) for why that matters.
import or mount time. [How a page ends up with one
viewer](#-how-a-page-ends-up-with-one-viewer) explains why that matters.

Use the public `getViewer()` API to open the viewer from your own code. It returns
a shared `Viewer` instance:
Expand Down Expand Up @@ -266,16 +417,21 @@ has it sorted and sorts the folder the same way, so paging through it matches
what the user would see in the list. A public share has no such setting, and
neither does a request that fails: both fall back to names ascending.

`ViewerOptions` lets you hook into navigation and paging:

| Option | Type | Description |
| ---------- | ------------------------- | --------------------------------------------------------------- |
| `loadMore` | `() => Promise<File[]>` | Called to append more files when reaching the end of the list |
| `onPrev` | `() => void` | Called when navigating to the previous item |
| `onNext` | `() => void` | Called when navigating to the next item |
| `onClose` | `() => void` | Called when the viewer is closed |
| `canLoop` | `boolean` | Whether navigation loops from last to first item and vice versa |
| `startSlideshow` | `boolean` | Whether to start the slideshow on open, given more than one file |
`ViewerOptions` lets you hook into navigation and paging. All of them are optional:

| Option | Type | Description |
| ----------------- | ----------------------------- | -------------------------------------------------------------------- |
| `loadMore` | `() => Promise<File[]>` | Called to append more files when reaching the end of the list |
| `onPrev` | `(file: File) => void` | Called with the file navigated to, going back |
| `onNext` | `(file: File) => void` | Called with the file navigated to, going forward |
| `onClose` | `() => void` | Called when the viewer is closed |
| `canLoop` | `boolean` | Whether navigation loops from last to first and back. Defaults to `true` |
| `startSlideshow` | `boolean` | Whether to start the slideshow on open, given more than one file |
| `editing` | `boolean` | Open straight into editing mode, for a handler that can edit |
| `onEditingChange` | `(editing: boolean) => void` | Called when editing mode changes, e.g. to keep it in the URL |
| `enableSidebar` | `boolean` | Whether to offer the Files sidebar. Defaults to `true`, turn it off for files it cannot resolve, like an old version |
| `view` | `View` | The Files view the viewer was opened from, handed to the file actions in its header |
| `folder` | `Folder` | The folder the files live in, handed to those actions as well |

### 🧭 Migrating from `OCA.Viewer`

Expand All @@ -299,15 +455,18 @@ instead, and the viewer works with `@nextcloud/files` nodes rather than the
| `OCA.Viewer.registerHandler({ component })` | `registerHandler({ tagname })`, see above |
| `canCompare: true` on a handler | nothing, any handler can be compared |
| `\OCP\Util::addScript` for the registration | `\OCP\Util::addInitScript` |
| A listener for `OCA\Viewer\Event\LoadViewer` | a listener for `BeforeTemplateRenderedEvent`, see the [tutorial](#4-load-it-on-every-page) |
| Dispatching `OCA\Viewer\Event\LoadViewer` | nothing, the viewer is on every page already |

Inside a handler, what used to be read off the global comes in as props:

| Before | Now |
| -------------------------- | ------------------------------------------------------- |
| `OCA.Viewer.file` | the `file` prop |
| `OCA.Viewer.list` | the `files` prop |
| `OCA.Viewer.enableSidebar` | the `isSidebarShown` prop |
| `OCA.Viewer.enableSidebar` | no equivalent, it is the opener's option; `isSidebarShown` says whether it is open right now |
| `OCA.Viewer.loadMore` | no equivalent, the viewer calls it and handles the list |
| `OCA.Viewer.onPrev`, `onNext`, `onClose`, `canLoop` | no equivalent, they belong to whoever opened the viewer |

Two changes are worth calling out because they are not a rename:

Expand All @@ -318,7 +477,10 @@ what lets the viewer render a handler written in any framework, or none. The

Lists and files are `@nextcloud/files` nodes. There is no path-based entry point
any more: build the nodes you already have, or hand `openFolder()` a folder and
let it fetch. `fileinfo` objects are not accepted.
let it fetch. `fileinfo` objects are not accepted. A node you build by hand should
carry what a WebDAV response would, `permissions` above all: the viewer hides the
download, rotate and edit actions for a node without them. When in doubt, ask
WebDAV for it, as the [tutorial](#-opening-it-yourself) does.

> [!TIP]
> If you feel like your mime should be integrated in this repo, you can also create
Expand Down Expand Up @@ -366,7 +528,7 @@ warns in the console, naming what it found and which one will run. Only one of
them can, and the apps that pinned the other expect behaviour it may not have.

The election happens once, on the first call to `open()`, `openFolder()` or
`compare()` — whichever candidate is newest **at that moment** wins, and every
`compare()`. Whichever candidate is newest **at that moment** wins, and every
handler registration script on the page runs synchronously during page load, so
by the time a user can click anything, every candidate is already in. Calling
`open()` yourself before that point, e.g. eagerly at import or mount, can elect
Expand Down
Loading