diff --git a/__tests__/sidebar/sidebarTab.spec.ts b/__tests__/sidebar/sidebarTab.spec.ts index 503a907d3..0c2dcdcef 100644 --- a/__tests__/sidebar/sidebarTab.spec.ts +++ b/__tests__/sidebar/sidebarTab.spec.ts @@ -6,7 +6,7 @@ import type { IFolder, INode, IView } from '../../lib/index.ts' import { beforeEach, describe, expect, it, Mock, vi } from 'vitest' -import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar' +import { getSidebarTabs, ISidebarTab, registerSidebarTab } from '../../lib/sidebar/SidebarTab.ts' // missing in JSDom but supported by every browser! import 'css.escape' diff --git a/lib/sidebar/Sidebar.ts b/lib/sidebar/Sidebar.ts new file mode 100644 index 000000000..ab33bb762 --- /dev/null +++ b/lib/sidebar/Sidebar.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ISidebarContext, ISidebarTab } from './SidebarTab.ts' + +import { getSidebarTabs, registerSidebarTab } from './SidebarTab.ts' + +export interface ISidebar { + /** + * If the files sidebar can currently be accessed. + * Registering tabs also works if the sidebar is currently not available. + */ + readonly available: boolean + + /** + * The current open state of the sidebar + */ + readonly open: boolean + + /** + * Open or close the sidebar + * + * @param open - The new open state + */ + setOpen(open: boolean): void + + /** + * Register a new sidebar tab + * + * @param tab - The sidebar tab to register + */ + registerTab(tab: ISidebarTab): void + + /** + * Get all registered sidebar tabs. + * If a node is passed only the enabled tabs are retrieved. + */ + getTabs(context?: ISidebarContext): ISidebarTab[] +} + +/** + * This is just a proxy allowing an arbitrary `@nextcloud/files` library version to access the defined interface of the sidebar. + * By proxying this instead of providing the implementation here we ensure that if apps use different versions of the library, + * we do not end up with version conflicts between them. + * + * If we add new properties they just will be available in new library versions. + * If we decide to do a breaking change we can either add compatibility wrappers in the implementation in the files app. + */ +class SidebarProxy implements ISidebar { + + get available(): boolean { + return !!window.OCA?.Files?.Sidebar + } + + get open(): boolean { + return !!window.OCA?.Files?.Sidebar?.state.file + } + + setOpen(open: boolean): void { + if (open) { + window.OCA?.Files?.Sidebar?.open() + } else { + window.OCA?.Files?.Sidebar?.close() + } + } + + setActiveTab(tabId: string): void { + window.OCA?.Files?.Sidebar?.setActiveTab(tabId) + } + + registerTab(tab: ISidebarTab): void { + registerSidebarTab(tab) + } + + getTabs(context?: ISidebarContext): ISidebarTab[] { + const tabs = getSidebarTabs() + if (context) { + return tabs.filter((tab) => tab.enabled(context)) + } + return tabs + } + +} + +/** + * Get a reference to the files sidebar. + */ +export function getSidebar(): ISidebar { + return new SidebarProxy() +} diff --git a/lib/sidebar/SidebarTab.ts b/lib/sidebar/SidebarTab.ts new file mode 100644 index 000000000..0ef3afe2f --- /dev/null +++ b/lib/sidebar/SidebarTab.ts @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IView } from '../navigation/view.ts' +import type { IFolder, INode } from '../node/index.ts' + +import isSvg from 'is-svg' +import logger from '../utils/logger.ts' + +export interface ISidebarContext { + /** + * The active node in the sidebar + */ + node: INode + + /** + * The current open folder in the files app + */ + folder: IFolder + + /** + * The currently active view + */ + view: IView +} + +/** + * This component describes the custom web component that should be registered for a sidebar tab. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components + * @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue + */ +export interface SidebarComponent extends HTMLElement, ISidebarContext { + /** + * This method is called by the files app if the sidebar tab state changes. + * + * @param active - The new active state + */ + setActive(active: boolean): Promise +} + +/** + * Implementation of a custom sidebar tab within the files app. + */ +export interface ISidebarTab { + /** + * Unique id of the sidebar tab. + * This has to conform to the HTML id attribute specification. + */ + id: string + + /** + * The localized name of the sidebar tab. + */ + displayName: string + + /** + * The icon, as SVG, of the sidebar tab. + */ + iconSvgInline: string + + /** + * The order of this tab. + * Use a low number to make this tab ordered in front. + */ + order: number + + /** + * The tag name of the web component. + * The web component must already be registered under that tag name with `CustomElementRegistry.define()`. + * + * To avoid name clashes the name has to start with your appid (e.g. `your_app`). + * So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`. + */ + tagName: string + + /** + * Callback to check if the sidebar tab should be shown for the selected node. + * + * @param context - The current context of the files app + */ + enabled: (context: ISidebarContext) => boolean +} + +/** + * Register a new sidebar tab for the files app. + * + * @param tab - The sidebar tab to register + * @throws If the provided tab is not a valid sidebar tab and thus cannot be registered. + */ +export function registerSidebarTab(tab: ISidebarTab): void { + validateSidebarTab(tab) + + window._nc_files_sidebar_tabs ??= new Map() + if (window._nc_files_sidebar_tabs.has(tab.id)) { + logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`) + return + } + window._nc_files_sidebar_tabs.set(tab.id, tab) + logger.debug(`New sidebar tab with id "${tab.id}" registered.`) +} + +/** + * Get all currently registered sidebar tabs. + */ +export function getSidebarTabs(): ISidebarTab[] { + if (window._nc_files_sidebar_tabs) { + return [...window._nc_files_sidebar_tabs.values()] + } + return [] +} + +/** + * Check if a given sidebar tab objects implements all necessary fields. + * + * @param tab - The sidebar tab to validate + */ +function validateSidebarTab(tab: ISidebarTab): void { + if (typeof tab !== 'object') { + throw new Error('Sidebar tab is not an object') + } + + if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) { + throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications') + } + + if (!tab.tagName || typeof tab.tagName !== 'string') { + throw new Error('Sidebar tabs need to have the tagName name set') + } + + if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) { + throw new Error('Sidebar tabs tagName name is invalid') + } + + if (!tab.displayName || typeof tab.displayName !== 'string') { + throw new Error('Sidebar tabs need to have a name set') + } + + if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) { + throw new Error('Sidebar tabs need to have an valid SVG icon') + } + + if (typeof tab.order !== 'number') { + throw new Error('Sidebar tabs need to have a numeric order set') + } + + if (typeof tab.enabled !== 'function') { + throw new Error('Sidebar tabs need to have an "enabled" method') + } + + // now check the custom element constructor + const tagConstructor = window.customElements.get(tab.tagName) + if (!tagConstructor) { + throw new Error('Sidebar tab element not registered') + } + + if (!('setActive' in tagConstructor.prototype)) { + // we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype. + throw new Error('Sidebar tab elements must have the `setActive` method') + } +} diff --git a/lib/sidebar/index.ts b/lib/sidebar/index.ts index 0ef3afe2f..8b5b93b6b 100644 --- a/lib/sidebar/index.ts +++ b/lib/sidebar/index.ts @@ -3,161 +3,5 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IView } from '../navigation/view.ts' -import type { IFolder, INode } from '../node/index.ts' - -import isSvg from 'is-svg' -import logger from '../utils/logger.ts' - -export interface ISidebarContext { - /** - * The active node in the sidebar - */ - node: INode - - /** - * The current open folder in the files app - */ - folder: IFolder - - /** - * The currently active view - */ - view: IView -} - -/** - * This component describes the custom web component that should be registered for a sidebar tab. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_components - * @see https://vuejs.org/guide/extras/web-components#building-custom-elements-with-vue - */ -export interface SidebarComponent extends HTMLElement, ISidebarContext { - /** - * This method is called by the files app if the sidebar tab state changes. - * - * @param active - The new active state - */ - setActive(active: boolean): Promise -} - -/** - * Implementation of a custom sidebar tab within the files app. - */ -export interface ISidebarTab { - /** - * Unique id of the sidebar tab. - * This has to conform to the HTML id attribute specification. - */ - id: string - - /** - * The localized name of the sidebar tab. - */ - displayName: string - - /** - * The icon, as SVG, of the sidebar tab. - */ - iconSvgInline: string - - /** - * The order of this tab. - * Use a low number to make this tab ordered in front. - */ - order: number - - /** - * The tag name of the web component. - * The web component must already be registered under that tag name with `CustomElementRegistry.define()`. - * - * To avoid name clashes the name has to start with your appid (e.g. `your_app`). - * So in addition with the web component naming rules a good name would be `your_app-files-sidebar-tab`. - */ - tagName: string - - /** - * Callback to check if the sidebar tab should be shown for the selected node. - * - * @param context - The current context of the files app - */ - enabled: (context: ISidebarContext) => boolean -} - -/** - * Register a new sidebar tab for the files app. - * - * @param tab - The sidebar tab to register - * @throws If the provided tab is not a valid sidebar tab and thus cannot be registered. - */ -export function registerSidebarTab(tab: ISidebarTab): void { - validateSidebarTab(tab) - - window._nc_files_sidebar_tabs ??= new Map() - if (window._nc_files_sidebar_tabs.has(tab.id)) { - logger.warn(`Sidebar tab with id "${tab.id}" already registered. Skipping.`) - return - } - window._nc_files_sidebar_tabs.set(tab.id, tab) - logger.debug(`New sidebar tab with id "${tab.id}" registered.`) -} - -/** - * Get all currently registered sidebar tabs. - */ -export function getSidebarTabs(): ISidebarTab[] { - if (window._nc_files_sidebar_tabs) { - return [...window._nc_files_sidebar_tabs.values()] - } - return [] -} - -/** - * Check if a given sidebar tab objects implements all necessary fields. - * - * @param tab - The sidebar tab to validate - */ -function validateSidebarTab(tab: ISidebarTab): void { - if (typeof tab !== 'object') { - throw new Error('Sidebar tab is not an object') - } - - if (!tab.id || (typeof tab.id !== 'string') || tab.id !== CSS.escape(tab.id)) { - throw new Error('Sidebar tabs need to have an id conforming to the HTML id attribute specifications') - } - - if (!tab.tagName || typeof tab.tagName !== 'string') { - throw new Error('Sidebar tabs need to have the tagName name set') - } - - if (!tab.tagName.match(/^[a-z][a-z0-9-_]+$/)) { - throw new Error('Sidebar tabs tagName name is invalid') - } - - if (!tab.displayName || typeof tab.displayName !== 'string') { - throw new Error('Sidebar tabs need to have a name set') - } - - if (typeof tab.iconSvgInline !== 'string' || !isSvg(tab.iconSvgInline)) { - throw new Error('Sidebar tabs need to have an valid SVG icon') - } - - if (typeof tab.order !== 'number') { - throw new Error('Sidebar tabs need to have a numeric order set') - } - - if (typeof tab.enabled !== 'function') { - throw new Error('Sidebar tabs need to have an "enabled" method') - } - - // now check the custom element constructor - const tagConstructor = window.customElements.get(tab.tagName) - if (!tagConstructor) { - throw new Error('Sidebar tab element not registered') - } - - if (!('setActive' in tagConstructor.prototype)) { - // we cannot check properties like `node` or `view` because those are not necessarily defined in the prototype. - throw new Error('Sidebar tab elements must have the `setActive` method') - } -} +export type { ISidebarContext, ISidebarTab, SidebarComponent } from './SidebarTab.ts' +export * from './Sidebar.ts' diff --git a/lib/window.d.ts b/lib/window.d.ts index 84b1c5007..b7765aa31 100644 --- a/lib/window.d.ts +++ b/lib/window.d.ts @@ -20,7 +20,9 @@ export {} declare global { interface Window { - OC: Nextcloud.v27.OC | Nextcloud.v28.OC | Nextcloud.v29.OC; + OC: Nextcloud.v32.OC + // eslint-disable-next-line @typescript-eslint/no-explicit-any + OCA: any _nc_dav_namespaces?: DavProperty _nc_dav_properties?: string[] _nc_fileactions?: FileAction[]