import fs from 'fs' import path from 'path' import { app, ipcMain, Menu } from 'electron' import log from 'electron-log' import { ensureDirSync, isDirectory, isFile } from 'common/filesystem' import { isLinux } from '../config' import { parseMenu } from '../keyboard/shortcutHandler' import configureMenu, { configSettingMenu } from '../menu/templates' export const MenuType = { DEFAULT: 0, EDITOR: 1, SETTINGS: 2 } class AppMenu { /** * @param {Preference} preferences The preferences instances. * @param {Keybindings} keybindings The keybindings instances. * @param {string} userDataPath The user data path. */ constructor (preferences, keybindings, userDataPath) { const FILE_NAME = 'recently-used-documents.json' this.MAX_RECENTLY_USED_DOCUMENTS = 12 this._preferences = preferences this._keybindings = keybindings this._userDataPath = userDataPath this.RECENTS_PATH = path.join(userDataPath, FILE_NAME) this.isOsxOrWindows = /darwin|win32/.test(process.platform) this.isOsx = process.platform === 'darwin' this.activeWindowId = -1 this.windowMenus = new Map() this._listenForIpcMain() } addRecentlyUsedDocument (filePath) { const { isOsxOrWindows, isOsx, MAX_RECENTLY_USED_DOCUMENTS, RECENTS_PATH } = this if (isOsxOrWindows) app.addRecentDocument(filePath) if (isOsx) return const recentDocuments = this.getRecentlyUsedDocuments() const index = recentDocuments.indexOf(filePath) let needSave = index !== 0 if (index > 0) { recentDocuments.splice(index, 1) } if (index !== 0) { recentDocuments.unshift(filePath) } if (recentDocuments.length > MAX_RECENTLY_USED_DOCUMENTS) { needSave = true recentDocuments.splice(MAX_RECENTLY_USED_DOCUMENTS, recentDocuments.length - MAX_RECENTLY_USED_DOCUMENTS) } this.updateAppMenu(recentDocuments) if (needSave) { ensureDirSync(this._userDataPath) const json = JSON.stringify(recentDocuments, null, 2) fs.writeFileSync(RECENTS_PATH, json, 'utf-8') } } getRecentlyUsedDocuments () { const { RECENTS_PATH, MAX_RECENTLY_USED_DOCUMENTS } = this if (!isFile(RECENTS_PATH)) { return [] } try { const recentDocuments = JSON.parse(fs.readFileSync(RECENTS_PATH, 'utf-8')) .filter(f => f && (isFile(f) || isDirectory(f))) if (recentDocuments.length > MAX_RECENTLY_USED_DOCUMENTS) { recentDocuments.splice(MAX_RECENTLY_USED_DOCUMENTS, recentDocuments.length - MAX_RECENTLY_USED_DOCUMENTS) } return recentDocuments } catch (err) { log.error(err) return [] } } clearRecentlyUsedDocuments () { const { isOsxOrWindows, isOsx, RECENTS_PATH } = this if (isOsxOrWindows) app.clearRecentDocuments() if (isOsx) return const recentDocuments = [] this.updateAppMenu(recentDocuments) const json = JSON.stringify(recentDocuments, null, 2) ensureDirSync(this._userDataPath) fs.writeFileSync(RECENTS_PATH, json, 'utf-8') } addDefaultMenu (windowId) { const { windowMenus } = this const menu = this.buildSettingMenu() // Setting menu is also the fallback menu. windowMenus.set(windowId, menu) } addSettingMenu (window) { const { windowMenus } = this const menu = this.buildSettingMenu() windowMenus.set(window.id, menu) } addEditorMenu (window, options = {}) { const { windowMenus } = this windowMenus.set(window.id, this.buildEditorMenu(true)) const { menu, shortcutMap } = windowMenus.get(window.id) const currentMenu = Menu.getApplicationMenu() // the menu may be null updateMenuItemSafe(currentMenu, menu, 'sourceCodeModeMenuItem', !!options.sourceCodeModeEnabled) updateMenuItemSafe(currentMenu, menu, 'typewriterModeMenuItem', false) // FIXME: Focus mode is being ignored when you open a new window - inconsistency. // updateMenuItemSafe(currentMenu, menu, 'focusModeMenuItem', false) const { checked: isSourceMode } = menu.getMenuItemById('sourceCodeModeMenuItem') if (isSourceMode) { // BUG: When opening a file `typewriterMode` and `focusMode` will be reset by editor. // If source code mode is set the editor must not change the values. const typewriterModeMenuItem = menu.getMenuItemById('typewriterModeMenuItem') const focusModeMenuItem = menu.getMenuItemById('focusModeMenuItem') typewriterModeMenuItem.enabled = false focusModeMenuItem.enabled = false } this._keybindings.registerKeyHandlers(window, shortcutMap) } removeWindowMenu (windowId) { // NOTE: Shortcut handler is automatically unregistered when window is closed. const { activeWindowId } = this this.windowMenus.delete(windowId) if (activeWindowId === windowId) { this.activeWindowId = -1 } } getWindowMenuById (windowId) { const menu = this.windowMenus.get(windowId) if (!menu) { log.error(`getWindowMenuById: Cannot find window menu for id ${windowId}.`) throw new Error(`Cannot find window menu for id ${windowId}.`) } return menu.menu } has (windowId) { return this.windowMenus.has(windowId) } setActiveWindow (windowId) { if (this.activeWindowId !== windowId) { // Change application menu to the current window menu. this._setApplicationMenu(this.getWindowMenuById(windowId)) this.activeWindowId = windowId } } buildEditorMenu (createShortcutMap, recentUsedDocuments) { if (!recentUsedDocuments) { recentUsedDocuments = this.getRecentlyUsedDocuments() } const menuTemplate = configureMenu(this._keybindings, this._preferences, recentUsedDocuments) const menu = Menu.buildFromTemplate(menuTemplate) let shortcutMap = null if (createShortcutMap) { shortcutMap = parseMenu(menuTemplate) } return { shortcutMap, menu, type: MenuType.EDITOR } } buildSettingMenu () { if (this.isOsx) { const menuTemplate = configSettingMenu(this._keybindings) const menu = Menu.buildFromTemplate(menuTemplate) return { menu, type: MenuType.SETTINGS } } return { menu: null, type: MenuType.SETTINGS } } updateAppMenu (recentUsedDocuments) { if (!recentUsedDocuments) { recentUsedDocuments = this.getRecentlyUsedDocuments() } // "we don't support changing menu object after calling setMenu, the behavior // is undefined if user does that." That mean we have to recreate the editor // application menu each time. // rebuild all window menus this.windowMenus.forEach((value, key) => { const { menu: oldMenu, type } = value if (type !== MenuType.EDITOR) return const { menu: newMenu } = this.buildEditorMenu(false, recentUsedDocuments) // all other menu items are set automatically updateMenuItem(oldMenu, newMenu, 'sourceCodeModeMenuItem') updateMenuItem(oldMenu, newMenu, 'typewriterModeMenuItem') updateMenuItem(oldMenu, newMenu, 'focusModeMenuItem') updateMenuItem(oldMenu, newMenu, 'sideBarMenuItem') updateMenuItem(oldMenu, newMenu, 'tabBarMenuItem') // update window menu value.menu = newMenu // update application menu if necessary const { activeWindowId } = this if (activeWindowId === key) { this._setApplicationMenu(newMenu) } }) } updateLineEndingMenu (lineEnding) { updateLineEndingMenu(lineEnding) } updateAlwaysOnTopMenu (flag) { const menus = Menu.getApplicationMenu() const menu = menus.getMenuItemById('alwaysOnTopMenuItem') menu.checked = flag } updateThemeMenu = theme => { this.windowMenus.forEach((value, key) => { const { menu, type } = value if (type !== MenuType.EDITOR) return const themeMenus = menu.getMenuItemById('themeMenu') if (!themeMenus) { return } themeMenus.submenu.items.forEach(item => (item.checked = false)) themeMenus.submenu.items .forEach(item => { if (item.id && item.id === theme) { item.checked = true } }) }) } updateAutoSaveMenu = autoSave => { this.windowMenus.forEach((value, key) => { const { menu, type } = value if (type !== MenuType.EDITOR) return const autoSaveMenu = menu.getMenuItemById('autoSaveMenuItem') if (!autoSaveMenu) { return } autoSaveMenu.checked = autoSave }) } updateAidouMenu = bool => { this.windowMenus.forEach((value, key) => { const { menu, type } = value if (type !== MenuType.EDITOR) return const aidouMenu = menu.getMenuItemById('aidou') if (!aidouMenu) { return } aidouMenu.visible = bool }) } _setApplicationMenu (menu) { if (isLinux && !menu) { // WORKAROUND for Electron#16521: We cannot hide the (application) menu on Linux. const dummyMenu = Menu.buildFromTemplate([]) Menu.setApplicationMenu(dummyMenu) } else { Menu.setApplicationMenu(menu) } } _listenForIpcMain () { ipcMain.on('mt::add-recently-used-document', (e, pathname) => { this.addRecentlyUsedDocument(pathname) }) ipcMain.on('menu-add-recently-used', pathname => { this.addRecentlyUsedDocument(pathname) }) ipcMain.on('menu-clear-recently-used', () => { this.clearRecentlyUsedDocuments() }) ipcMain.on('broadcast-preferences-changed', prefs => { if (prefs.theme !== undefined) { this.updateThemeMenu(prefs.theme) } if (prefs.autoSave !== undefined) { this.updateAutoSaveMenu(prefs.autoSave) } if (prefs.aidou !== undefined) { this.updateAidouMenu(prefs.aidou) } }) } } const updateMenuItem = (oldMenus, newMenus, id) => { const oldItem = oldMenus.getMenuItemById(id) const newItem = newMenus.getMenuItemById(id) newItem.checked = oldItem.checked } const updateMenuItemSafe = (oldMenus, newMenus, id, defaultValue) => { let checked = defaultValue if (oldMenus) { const oldItem = oldMenus.getMenuItemById(id) if (oldItem) { checked = oldItem.checked } } const newItem = newMenus.getMenuItemById(id) newItem.checked = checked } // ---------------------------------------------- // HACKY: We have one application menu per window and switch the menu when // switching windows, so we can access and change the menu items via Electron. /** * Return the menu from the application menu. * * @param {string} menuId Menu ID * @returns {Electron.Menu} Returns the menu or null. */ export const getMenuItemById = menuId => { const menus = Menu.getApplicationMenu() return menus.getMenuItemById(menuId) } export const updateLineEndingMenu = lineEnding => { const menus = Menu.getApplicationMenu() const crlfMenu = menus.getMenuItemById('crlfLineEndingMenuEntry') const lfMenu = menus.getMenuItemById('lfLineEndingMenuEntry') if (lineEnding === 'crlf') { crlfMenu.checked = true } else { lfMenu.checked = true } } export default AppMenu