Skip to content

Commit fe41614

Browse files
committed
feat: viewer 6.0.0
Signed-off-by: John Molakvoæ (skjnldsv) <[email protected]>
1 parent b0d1ae2 commit fe41614

File tree

18 files changed

+793
-95
lines changed

18 files changed

+793
-95
lines changed

src/api_package/custom.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
declare module '*.svg?raw' {
6+
const content: string
7+
export default content
8+
}
9+
10+
declare module '*.vue' {
11+
import Vue from 'vue'
12+
export default Vue
13+
}

src/api_package/global.d.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
*/
55

66
import type { IHandler } from './index.ts'
7+
import type { Viewer } from './viewer.ts'
78

89
declare global {
910
interface Window {
1011
/**
1112
* Registered viewer handlers.
1213
*/
13-
// eslint-disable-next-line camelcase
14-
_oca_viewer_handlers: Map<string, IHandler>
14+
_nc_viewer_handlers: Map<string, IHandler>
15+
16+
/**
17+
* The viewer global instance.
18+
*/
19+
_nc_viewer_service: Viewer
1520
}
1621
}

src/api_package/index.ts

Lines changed: 160 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,105 @@
22
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import type { File, Node } from '@nextcloud/files'
56

6-
import type { AsyncComponent, Component } from 'vue'
7+
import { DefaultType, FileAction, FileType, getFileActions, registerFileAction } from '@nextcloud/files'
8+
import FileSvg from '@mdi/svg/svg/file.svg?raw'
9+
import OpenInAppSvg from '@mdi/svg/svg/open-in-app.svg?raw'
710

11+
import { getViewer } from './viewer.ts'
12+
import { t } from '@nextcloud/l10n'
13+
import { logger } from '../services/logger.ts'
14+
15+
const ACTION_VIEWER = 'viewer-open'
816
export interface IHandler {
917
/**
1018
* Unique identifier for the handler
1119
*/
1220
id: string
1321

1422
/**
15-
* Indicate support for comparing two files
23+
* The handler translated name
1624
*/
17-
canCompare?: boolean
25+
displayName: string
1826

1927
/**
20-
* Vue 2 component to render the file.
28+
* Optional icon for the handler
2129
*/
22-
component: Component | AsyncComponent
30+
iconSvgInline?: string
2331

2432
/**
25-
* Group identifier to combine for navigating to the next/previous files
33+
* The custom element tag name to use for this handler.
34+
*/
35+
tagname: string
36+
37+
/**
38+
* Identifier to group handlers by.
39+
* When opening a folder we'll check
40+
* against all handlers that are enabled
41+
* for the given group AND matches the
42+
* group property.
2643
*/
2744
group?: string
2845

2946
/**
30-
* List of mime types that are supported for opening
47+
* Is this enabled for the given mimes ?
3148
*/
32-
mimes?: string[]
49+
enabled: (nodes: File[]) => boolean
3350

3451
/**
35-
* Aliases for mime types, used to map different mime types to the same handler.
52+
* Optional function to preload data for the given node.
53+
* This will be called for the previous and next nodes on
54+
* opening a file to allow the handler to be faster when navigating.
55+
*
56+
* @param node - The node to preload data for
57+
* @returns A promise that resolves when the data is preloaded
3658
*/
37-
mimesAliases?: Record<string, string>
59+
preload?: (node: File) => Promise<void>
3860

3961
/**
4062
* Viewer modal theme (one of 'dark', 'light', 'default')
4163
*/
4264
theme?: 'dark' | 'light' | 'default'
4365
}
4466

67+
const topLevelViewerAction = new FileAction({
68+
id: ACTION_VIEWER,
69+
displayName: () => t('viewer', 'Open with …'),
70+
iconSvgInline: () => OpenInAppSvg,
71+
order: -1000,
72+
73+
enabled: (files: Node[]) => {
74+
if (files.length === 0) {
75+
return false
76+
}
77+
78+
// We do not support folders
79+
if (files.some(file => file.type !== FileType.File)) {
80+
return false
81+
}
82+
83+
// Check if we have more than one handler that can handle this mime
84+
// If yes, we return true to show the "Open with ..." menu
85+
let supportedHandlerCount = 0
86+
for (const handler of getHandlers().values()) {
87+
if (handler.enabled(files)) {
88+
supportedHandlerCount++
89+
}
90+
// TODO: Change to 1
91+
if (supportedHandlerCount > 0) {
92+
return true
93+
}
94+
}
95+
96+
logger.debug('No hander found for the given nodes', { files })
97+
return false
98+
},
99+
exec() {
100+
return Promise.resolve(null)
101+
}
102+
})
103+
45104
/**
46105
* Register a new handler for the viewer.
47106
* This needs to be called before the viewer is initialized to ensure the handler is available.
@@ -53,35 +112,112 @@ export interface IHandler {
53112
export function registerHandler(handler: IHandler): void {
54113
validateHandler(handler)
55114

56-
window._oca_viewer_handlers ??= new Map<string, IHandler>()
57-
if (window._oca_viewer_handlers.has(handler.id)) {
115+
window._nc_viewer_handlers ??= new Map<string, IHandler>()
116+
if (window._nc_viewer_handlers.has(handler.id)) {
58117
console.warn(`Handler with id ${handler.id} is already registered.`)
59118
return
60119
}
61120

62-
window._oca_viewer_handlers.set(handler.id, handler)
121+
window._nc_viewer_handlers.set(handler.id, handler)
122+
123+
registerFileAction(new FileAction({
124+
id: `${ACTION_VIEWER}-${handler.id}`,
125+
// TRANSLATORS: handler is the translated name of the handler.
126+
displayName: () => t('viewer', 'Open with {handler}', { handler: handler.displayName }),
127+
128+
iconSvgInline: () => handler.iconSvgInline ?? FileSvg,
129+
parent: ACTION_VIEWER,
130+
order: -999,
131+
default: DefaultType.HIDDEN,
132+
133+
enabled: (files: Node[]) => {
134+
if (files.length === 0) {
135+
return false
136+
}
137+
138+
// We do not support folders
139+
if (files.some(file => file.type !== FileType.File)) {
140+
return false
141+
}
142+
143+
return handler.enabled(files)
144+
},
145+
async exec(node: Node) {
146+
if (node.type !== FileType.File) {
147+
return null
148+
}
149+
150+
getViewer().open([node as File], node as File)
151+
return null
152+
}
153+
}))
154+
155+
// Only register the main "Open with ..." action if not already registered
156+
// This action will be shown if more than one handler is available for the given mime
157+
const actions = getFileActions()
158+
if (!actions.find(action => action.id === ACTION_VIEWER)) {
159+
registerFileAction(topLevelViewerAction)
160+
161+
logger.info('Registered top level viewer file action', { id: ACTION_VIEWER })
162+
}
163+
}
164+
165+
export function getHandlers() : Map<string, IHandler> {
166+
return window._nc_viewer_handlers ??= new Map<string, IHandler>()
63167
}
64168

65169
/**
66170
* Validate the handler object.
67171
*
68172
* @param handler - The handler to validate
69173
*/
70-
function validateHandler(handler: IHandler) {
71-
const { id, mimes, mimesAliases, component } = handler
174+
function validateHandler(handler: IHandler): void {
175+
const { id, displayName, group, enabled } = handler
176+
if (typeof id !== 'string' || id.trim() === '') {
177+
throw new Error('Handler id must be a non-empty string')
178+
}
179+
180+
if (typeof displayName !== 'string' || displayName.trim() === '') {
181+
throw new Error('Handler displayName must be a non-empty string')
182+
}
183+
184+
if (typeof handler.tagname !== 'string' || handler.tagname.trim() === '') {
185+
throw new Error('Handler tagname must be a non-empty string')
186+
}
187+
188+
if (group && (typeof group !== 'string' || group.trim() === '')) {
189+
throw new Error('Handler group must be a non-empty string if provided')
190+
}
191+
192+
if (typeof enabled !== 'function') {
193+
throw new Error('Handler enabled must be a function')
194+
}
72195

73-
// checking valid handler id
74-
if (!id || id.trim() === '' || typeof id !== 'string') {
75-
throw new Error('The handler does not have a valid id')
196+
if (handler.preload && typeof handler.preload !== 'function') {
197+
throw new Error('Handler preload must be a function if provided')
76198
}
77199

78-
// Nothing available to process! Failure
79-
if ((!mimes || !Array.isArray(mimes)) && !mimesAliases) {
80-
throw new Error('Handler needs a valid mime array or mimesAliases')
200+
if (handler.theme && !['dark', 'light', 'default'].includes(handler.theme)) {
201+
throw new Error("Handler theme must be one of 'dark', 'light', 'default' if provided")
81202
}
82203

83-
// checking valid handler component data
84-
if ((!component || (typeof component !== 'object' && typeof component !== 'function'))) {
85-
throw new Error('The handler does not have a valid component')
204+
validateCustomElementName(handler.tagname)
205+
}
206+
207+
function validateCustomElementName(tagname: string): void {
208+
if (!tagname.includes('-')) {
209+
throw new Error('Handler tagname must contain a hyphen (-)')
210+
}
211+
if (/^[A-Z]/.test(tagname)) {
212+
throw new Error('Handler tagname must not start with an uppercase letter')
213+
}
214+
if (/--/.test(tagname)) {
215+
throw new Error('Handler tagname must not contain consecutive hyphens (--)')
216+
}
217+
if (tagname.startsWith('-') || tagname.endsWith('-')) {
218+
throw new Error('Handler tagname must not start or end with a hyphen (-)')
219+
}
220+
if (!/^[a-z][a-z0-9-]*$/.test(tagname)) {
221+
throw new Error('Handler tagname must only contain lowercase letters, numbers, and hyphens (-)')
86222
}
87223
}

src/api_package/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"include": ["index.ts", "global.d.ts"],
2+
"include": ["*.ts"],
33
"compilerOptions": {
44
"lib": [
55
"DOM",

src/api_package/viewer.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { File, Folder } from '@nextcloud/files'
7+
import ViewerVue from '../views/Viewer.vue'
8+
9+
export type ViewerOptions = {
10+
loadMore: () => Promise<File[]>
11+
onPrev: () => void
12+
onNext: () => void
13+
onClose: () => void
14+
canLoop: boolean
15+
}
16+
17+
export interface ViewerAPI {
18+
open(nodes: File[], file?: File, options?: ViewerOptions, handlerId?: string): Promise<any>
19+
openFolder(folder: Folder, file?: File, options?: ViewerOptions, handlerId?: string): Promise<any>
20+
compare(node1: File, node2: File, handlerId?: string): Promise<any>
21+
}
22+
23+
export class Viewer extends EventTarget implements ViewerAPI {
24+
private viewer: InstanceType<typeof ViewerVue> | null = null
25+
26+
/**
27+
* Set the viewer instance (called from init.ts)
28+
* Private, do not use directly.
29+
*/
30+
_setViewer(viewer: InstanceType<typeof ViewerVue>) {
31+
this.viewer = viewer
32+
}
33+
34+
async open(nodes: File[], file?: File, options?: ViewerOptions, handlerId?: string): Promise<any> {
35+
if (!this.viewer) {
36+
throw new Error('Viewer is not initialized')
37+
}
38+
this.viewer.open(nodes, file, options, handlerId)
39+
}
40+
41+
async openFolder(folder: Folder, file?: File, options?: ViewerOptions, handlerId?: string): Promise<any> {
42+
if (!this.viewer) {
43+
throw new Error('Viewer is not initialized')
44+
}
45+
this.viewer.openFolder(folder, file, options, handlerId)
46+
}
47+
48+
async compare(node1: File, node2: File, handlerId?: string): Promise<any> {
49+
if (!this.viewer) {
50+
throw new Error('Viewer is not initialized')
51+
}
52+
this.viewer.compare(node1, node2, handlerId)
53+
}
54+
}
55+
56+
// Init and get the viewer in Modal
57+
export function getViewer(): Viewer {
58+
return window._nc_viewer_service ??= new Viewer()
59+
}
60+
61+
// Create a new Viewer instance in the given element
62+
export function createViewer(el: HTMLElement, file: File): Viewer {
63+
const instance = new Viewer()
64+
el.appendChild(instance)
65+
return instance
66+
}

src/components/ImageEditor.vue

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
</div>
99
</template>
1010
<script>
11-
import { showError, showSuccess, DialogBuilder } from '@nextcloud/dialogs'
11+
import { basename, dirname, extname, join } from 'path'
1212
import { emit } from '@nextcloud/event-bus'
13-
import { Node } from '@nextcloud/files'
1413
import { linkTo } from '@nextcloud/router'
14+
import { Node } from '@nextcloud/files'
15+
import { showError, showSuccess, DialogBuilder } from '@nextcloud/dialogs'
1516
import axios from '@nextcloud/axios'
16-
import { basename, dirname, extname, join } from 'path'
17-
import translations from '../models/editorTranslations.js'
17+
18+
import { logger } from '../services/logger.ts'
1819
import { rawStat } from '../services/FileInfo.ts'
19-
import logger from '../services/logger.js'
20+
import translations from '../models/editorTranslations.js'
2021
2122
let TABS, TOOLS
2223

0 commit comments

Comments
 (0)