import {DiffDOM} from "diff-dom" import {keyName} from "w3c-keyname" import type {EditorView} from "prosemirror-view" import { addAlert, avatarTemplate, cleanPath, escapeText, findTarget } from "fwtoolkit" import type {Editor} from "../../types.js" interface HeaderbarBaseItem { id?: string order?: number disabled?: (editor: Editor) => boolean selected?: (editor: Editor) => boolean available?: (editor: Editor) => boolean icon?: string } interface HeaderbarActionItem extends HeaderbarBaseItem { type: "action" title: string | ((editor: Editor) => string) tooltip?: string | ((editor: Editor) => string) icon?: string action: (editor: Editor) => void } interface HeaderbarSettingItem extends HeaderbarBaseItem { type: "setting" title: string | ((editor: Editor) => string) tooltip?: string | ((editor: Editor) => string) icon?: string action: (editor: Editor) => void } interface HeaderbarSeparatorItem extends HeaderbarBaseItem { type: "separator" } interface HeaderbarMenuItem extends HeaderbarBaseItem { type: "menu" title: string | ((editor: Editor) => string) tooltip?: string | ((editor: Editor) => string) keys?: string open: boolean content: HeaderbarItem[] } type HeaderbarItem = | HeaderbarActionItem | HeaderbarSettingItem | HeaderbarSeparatorItem | HeaderbarMenuItem interface HeaderbarModel { open: boolean content: HeaderbarMenuItem[] } export class HeaderbarView { editorView: EditorView options: {editor: Editor} editor: Editor dd: DiffDOM headerEl: Element listeners: { onclick?: (event: MouseEvent) => void onKeydown?: (event: KeyboardEvent) => void onKeyup?: (event: KeyboardEvent) => void onFocusout?: (event: FocusEvent) => void } parentChain: HeaderbarMenuItem[] openMenu: HeaderbarMenuItem | null cursorMenuItem: HeaderbarItem | null constructor(editorView: EditorView, options: {editor: Editor}) { this.editorView = editorView this.options = options this.editor = this.options.editor this.editor.menu.headerView = this this.dd = new DiffDOM({ valueDiffing: false }) const headerbar = document.querySelector("#headerbar") if (!headerbar?.firstElementChild) { throw new Error("Headerbar element not found.") } this.headerEl = headerbar.firstElementChild this.listeners = {} const model = this.options.editor.menu.headerbarModel as HeaderbarModel this.removeUnavailable(model) this.addMissingIds(model) this.bindEvents() this.update() this.parentChain = [] this.openMenu = null this.cursorMenuItem = null } removeUnavailable(menu: HeaderbarModel | HeaderbarMenuItem): void { // Remove those menu items from the menu model that are not available for this document. // Used for example for language or page size options that aren't permitted according to the // document template. menu.content = menu.content.filter(item => { if (item.available && !item.available(this.editor)) { return false } else if (item.type === "menu") { this.removeUnavailable(item) } return true }) } addMissingIds(menu: HeaderbarModel | HeaderbarMenuItem): void { // Add missing ids to menu items that don't have an ID. menu.content.forEach(item => { if (!item.id) { item.id = Math.random().toString(36).substring(2) } if (item.type === "menu") { this.addMissingIds(item) } }) } bindEvents(): void { this.listeners.onclick = event => this.onclick(event) this.editor.dom.addEventListener("click", this.listeners.onclick) this.listeners.onKeydown = event => this.onKeydown(event) this.editor.dom.addEventListener("keydown", this.listeners.onKeydown) this.listeners.onKeyup = event => this.onKeyup(event) this.editor.dom.addEventListener("keyup", this.listeners.onKeyup) this.listeners.onFocusout = event => this.onFocusout(event) this.editor.dom.addEventListener("focusout", this.listeners.onFocusout) } destroy(): void { if (document.activeElement?.id === "document-title") { this.saveFileName() } this.editor.dom.removeEventListener("click", this.listeners.onclick!) this.editor.dom.removeEventListener("keydown", this.listeners.onKeydown!) this.editor.dom.removeEventListener("keyup", this.listeners.onKeyup!) this.editor.dom.removeEventListener("focusout", this.listeners.onFocusout!) } onclick(event: MouseEvent): void { const target = event.target as HTMLElement | null if (!target) { return } const model = this.editor.menu.headerbarModel as HeaderbarModel if (target.closest("div#close-document-top a i.fa-times")) { // If the user is offline prevent the closing of the document. if (this.editor.app.isOffline()) { event.preventDefault() event.stopPropagation() addAlert( "info", gettext("You cannot close a document when you're offline.") ) } } else if ( target.closest("#headerbar #header-navigation .fw-pulldown-item") ) { // A header nav menu item was clicked. Now we just need to find // which one and execute the corresponding action. const item = target.closest( "#headerbar #header-navigation .fw-pulldown-item" ) const searchPath: number[] = [] let seekItem: Element | null = item while (seekItem?.closest("li")) { let itemNumber = 0 seekItem = seekItem.closest("li") while (seekItem?.previousElementSibling) { itemNumber++ seekItem = seekItem.previousElementSibling } searchPath.push(itemNumber) seekItem = seekItem?.parentElement ?? null } seekItem = seekItem?.closest("div.header-menu") ?? null let menuNumber = 0 while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } const menu = model.content[menuNumber] let menuItem: HeaderbarItem = menu while (searchPath.length) { const index = searchPath.pop() if ( menuItem.type === "menu" && index !== undefined && menuItem.content[index] ) { menuItem = menuItem.content[index] } } this.executeMenuItem(menuItem, menu) } else if ( target.closest( "#headerbar #header-navigation .header-nav-item:not(.fw-disabled)" ) ) { // A menu has been clicked, lets find out which one. const item = target.closest( "#headerbar #header-navigation .header-nav-item:not(.fw-disabled)" ) let menuNumber = 0 let seekItem: Element | null = item?.parentElement ?? null while (seekItem?.previousElementSibling) { menuNumber++ seekItem = seekItem.previousElementSibling } model.content.forEach(menu => { if (menu.open) { menu.open = false this.openMenu = null this.closeAllMenu(menu) this.parentChain = [] } }) model.content[menuNumber].open = true this.openMenu = model.content[menuNumber] this.parentChain = [this.openMenu] this.cursorMenuItem = null this.update() } else { let needUpdate = false this.cursorMenuItem = null model.content.forEach(menu => { if (menu.open) { needUpdate = true menu.open = false this.closeAllMenu(menu) this.parentChain = [] } }) if (needUpdate) { this.openMenu = null this.update() } } } executeMenuItem(menuItem: HeaderbarItem, menu: HeaderbarMenuItem): void { switch (menuItem.type) { case "action": if (menuItem.disabled?.(this.editor)) { return } menuItem.action(this.editor) menu.open = false this.openMenu = null this.closeAllMenu(menu) this.parentChain = [] this.cursorMenuItem = null this.update() break case "setting": // Similar to 'action' but not closing menu. if (menuItem.disabled?.(this.editor)) { return } menuItem.action(this.editor) this.update() break case "menu": { if (menuItem.disabled?.(this.editor)) { return } let flagCloseAllMenu = true if (!this.parentChain.length) { this.parentChain = [menuItem] if (this.openMenu) { this.openMenu.open = false } this.openMenu = menuItem this.openMenu.open = true } else { const isMenuItemInParentChain = this.parentChain[ this.parentChain.length - 1 ].content.find(menu => menu.id === menuItem.id) if (isMenuItemInParentChain) { //Do not close all open menus this.parentChain.push(menuItem) } else if (!isMenuItemInParentChain) { for ( let index = this.parentChain.length - 2; index >= 0; index-- ) { if ( this.parentChain[index].content.find( menu => menu.id === menuItem.id ) ) { const noOfRemovals = this.parentChain.length - (index + 1) if (noOfRemovals > 0) { //not last element this.parentChain.splice( index + 1, noOfRemovals ) } this.closeOtherMenu( this.parentChain[ this.parentChain.length - 1 ], menuItem ) this.parentChain.push(menuItem) flagCloseAllMenu = false break } } } if (flagCloseAllMenu && !isMenuItemInParentChain) { this.closeAllMenu(menu) this.parentChain = [menuItem] } } menuItem.open = true this.update() break } default: break } } closeAllMenu(menu: HeaderbarMenuItem): void { menu.content.forEach(menuItem => { if (menuItem.type === "menu" && menuItem.open) { menuItem.open = false this.closeAllMenu(menuItem) } }) } closeOtherMenu( menu: HeaderbarMenuItem, currentMenuItem: HeaderbarMenuItem ): void { menu.content.forEach(menuItem => { if (menuItem.type === "menu" && menuItem.open) { if ( !this.parentChain.includes(menuItem) && currentMenuItem != menuItem ) { menuItem.open = false } this.closeOtherMenu(menuItem, currentMenuItem) } }) } onKeydown(event: KeyboardEvent): void { if (findTarget(event, "h1#document-title")) { return } let name = keyName(event) if (event.altKey) { name = "Alt-" + name } if (event.ctrlKey) { name = "Ctrl-" + name } if (event.metaKey) { name = "Meta-" + name } if (event.shiftKey) { name = "Shift-" + name } if (this.openMenu) { if ( ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"].includes( name ) ) { event.preventDefault() event.stopPropagation() this.changeCursorMenuItem(name) return } else if (["Enter", " "].includes(name) && this.cursorMenuItem) { event.preventDefault() event.stopPropagation() const menuItem = this.cursorMenuItem if (menuItem.type === "menu") { this.cursorMenuItem = menuItem.content[0] } this.executeMenuItem( menuItem, this.parentChain[this.parentChain.length - 1] ) return } else if (name === "Escape") { event.preventDefault() event.stopPropagation() this.openMenu.open = false this.cursorMenuItem = null this.openMenu = null this.parentChain = [] this.update() return } } this.checkKeys(event, this.editor.menu.headerbarModel as HeaderbarModel, name) } onKeyup(event: KeyboardEvent): void { if (!findTarget(event, "h1#document-title")) { return } const docTitleEl = this.editor.dom.querySelector("h1#document-title") if ( !docTitleEl?.childNodes.length || (docTitleEl.childNodes.length === 1 && docTitleEl.firstChild?.nodeType === 3) ) { return } // Special key was pressed, we reset to text only and blur docTitleEl.innerHTML = (docTitleEl as HTMLElement).innerText .trim() .replace(/\r?\n|\r/g, "") ;(docTitleEl as HTMLElement).blur() } changeCursorMenuItem(name: string): void { const model = this.editor.menu.headerbarModel as HeaderbarModel if (!this.cursorMenuItem) { this.cursorMenuItem = this.parentChain[0].content[0] } else { if (["ArrowDown", "ArrowUp"].includes(name)) { let index = this.parentChain[ this.parentChain.length - 1 ].content.indexOf(this.cursorMenuItem) if (name === "ArrowDown") { if ( index < this.parentChain[this.parentChain.length - 1].content .length - 1 ) { index++ } else { index = 0 } } else { if (index > 0) { index-- } else { index = this.parentChain[this.parentChain.length - 1] .content.length - 1 } } this.cursorMenuItem = this.parentChain[this.parentChain.length - 1].content[index] } else if (name === "ArrowLeft") { if (this.parentChain.length > 1) { this.cursorMenuItem = this.parentChain[this.parentChain.length - 1] this.parentChain.pop() this.closeAllMenu( this.parentChain[this.parentChain.length - 1] ) } else { const currentMenuIndex = model.content.findIndex(menu => menu.open) const newMenuIndex = currentMenuIndex ? currentMenuIndex - 1 : model.content.length - 1 if (this.openMenu) { this.openMenu.open = false } this.openMenu = model.content[newMenuIndex] this.openMenu.open = true this.cursorMenuItem = this.openMenu.content[0] this.parentChain = [this.openMenu] } } else if (name === "ArrowRight") { if (this.cursorMenuItem.type === "menu") { const menuItem = this.cursorMenuItem this.cursorMenuItem = menuItem.content[0] this.executeMenuItem( menuItem, this.parentChain[this.parentChain.length - 1] ) } else { const currentMenuIndex = model.content.findIndex(menu => menu.open) const newMenuIndex = currentMenuIndex === model.content.length - 1 ? 0 : currentMenuIndex + 1 if (this.openMenu) { this.openMenu.open = false } this.openMenu = model.content[newMenuIndex] this.openMenu.open = true this.cursorMenuItem = this.openMenu.content[0] this.parentChain = [this.openMenu] } } } this.update() } getAccessKeyHTML(title: string, accessKey: string | undefined): string { if (!accessKey) { return escapeText(title) } const index = title.toLowerCase().indexOf(accessKey.toLowerCase()) if (index === -1) { return escapeText(title) } return `${escapeText(title.substring(0, index))}${escapeText(title.charAt(index))}${escapeText(title.substring(index + 1))}` } saveFileName(): void { if (this.editor.app.isOffline()) { // We are offline. Just reset. return this.update() } const docTitleEl = this.editor.dom.querySelector("h1#document-title") as HTMLElement | null if (!docTitleEl) { return } const path = cleanPath(this.getTitle(), docTitleEl.innerText.trim()) this.editor.docInfo.path = path this.editor.onPathChange?.(path) if (this.editor.ws) { this.editor.ws.send(() => { return { type: "path_change", path } }) } this.update() } onFocusout(event: FocusEvent): void { if (!findTarget(event, "h1#document-title")) { return } this.saveFileName() } checkKeys( event: KeyboardEvent, menu: HeaderbarModel | HeaderbarMenuItem, nameKey: string ): void { menu.content.forEach(menuItem => { if ("keys" in menuItem && menuItem.keys === nameKey) { event.preventDefault() // Now execute the menu item this.executeMenuItem(menuItem, menu as HeaderbarMenuItem) } else if (menuItem.type === "menu") { this.checkKeys(event, menuItem, nameKey) } }) } update(): void { if ( document.activeElement && document.activeElement.matches("h1#document-title") ) { return } const diff = this.dd.diff(this.headerEl, this.getHeaderHTML()) this.dd.apply(this.headerEl, diff) const model = this.editor.menu.headerbarModel as HeaderbarModel if (model.open) { this.editor.dom.classList.remove("header-closed") } else { this.editor.dom.classList.add("header-closed") } } getPathText(): string { let text = this.editor.docInfo.path if (text.length && !text.endsWith("/")) { return text } else if (text === "/") { text = "" } text += this.getTitle().replace(/\//g, "") || gettext("Untitled") return text } getTitle(): string { const doc = this.editor.view.state.doc let title = "" doc.firstChild?.forEach(child => { if (!child.marks.find(mark => mark.type.name === "deletion")) { title += escapeText(child.textContent) } }) return title.trim() } getHeaderHTML(): string { const model = this.editor.menu.headerbarModel as HeaderbarModel if (!model.open) { // header is closed return "
" } const editorOnlyMode = this.editor.app.settings.EDITOR_ONLY_MODE === true let closeTop = "" if (!editorOnlyMode) { let exitUrl: string if (this.editor.docInfo.token) { // Guest user — send to sign-up if open, otherwise to login exitUrl = this.editor.app.settings?.REGISTRATION_OPEN || this.editor.app.settings?.SOCIALACCOUNT_OPEN ? "/account/sign-up/" : "/" } else { const folderPath = this.editor.docInfo.path.slice( 0, this.editor.docInfo.path.lastIndexOf("/") ) exitUrl = !folderPath.length && this.editor.app.routes[""].app === "document" ? "/" : `/documents${encodeURI(folderPath)}/` } closeTop = `
` } return `
${closeTop}

${escapeText(this.getPathText())}

${this.getParticipantListHTML()}
` } getParticipantListHTML(): string { const participants = (this.editor.mod.collab as { participants: {id: number; username?: string; name?: string; avatar?: string}[] }).participants if (participants.length > 1) { return `
${participants .map(participant => avatarTemplate({user: participant})) .join("")}
` } else { return "" } } getHeaderNavHTML(): string { const model = this.editor.menu.headerbarModel as HeaderbarModel return model.content .map( menu => `
${this.getAccessKeyHTML(menu.title as string, menu.keys?.slice(-1))} ${menu.open ? this.getMenuHTML(menu) : ""}
` ) .join("") } getMenuHTML(menu: HeaderbarMenuItem): string { const title = typeof menu.title === "function" ? menu.title(this.editor) : menu.title return `` } getMenuItemHTML(menuItem: HeaderbarItem): string { let returnValue = "" switch (menuItem.type) { case "action": case "setting": returnValue = this.getActionMenuItemHTML(menuItem) break case "menu": returnValue = this.getMenuMenuItemHTML(menuItem) break case "separator": returnValue = "
" break default: break } return returnValue } getActionMenuItemHTML( menuItem: HeaderbarActionItem | HeaderbarSettingItem ): string { return ` ${menuItem.icon ? `` : ""} ${typeof menuItem.title === "function" ? menuItem.title(this.editor) : menuItem.title} ` } getMenuMenuItemHTML(menuItem: HeaderbarMenuItem): string { return ` ${menuItem.icon ? `` : ""} ${typeof menuItem.title === "function" ? menuItem.title(this.editor) : menuItem.title} ${menuItem.open ? this.getMenuHTML(menuItem) : ""}` } }