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'
816export 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 {
53112export 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 - z 0 - 9 - ] * $ / . test ( tagname ) ) {
221+ throw new Error ( 'Handler tagname must only contain lowercase letters, numbers, and hyphens (-)' )
86222 }
87223}
0 commit comments