676 lines
16 KiB
TypeScript
676 lines
16 KiB
TypeScript
import { AppState } from 'app/AppState'
|
|
import {
|
|
app,
|
|
BrowserWindow,
|
|
ContextMenuParams,
|
|
dialog,
|
|
Menu,
|
|
MenuItemConstructorOptions,
|
|
shell,
|
|
WebContents,
|
|
} from 'electron'
|
|
import { autorun } from 'mobx'
|
|
import { Store } from '../Store/Store'
|
|
import { StoreKeys } from '../Store/StoreKeys'
|
|
import { appMenu as str, contextMenu } from '../Strings'
|
|
import { TrayManager } from '../TrayManager'
|
|
import { autoUpdatingAvailable } from '../Types/Constants'
|
|
import { isLinux, isMac } from '../Types/Platforms'
|
|
import { checkForUpdate, openChangelog, showUpdateInstallationDialog } from '../UpdateManager'
|
|
import { handleTestMessage } from '../Utils/Testing'
|
|
import { isDev, isTesting } from '../Utils/Utils'
|
|
import { MessageType } from './../../../../test/TestIpcMessage'
|
|
import { BackupsManagerInterface } from './../Backups/BackupsManagerInterface'
|
|
import { SpellcheckerManager } from './../SpellcheckerManager'
|
|
import { MenuManagerInterface } from './MenuManagerInterface'
|
|
|
|
export const enum MenuId {
|
|
SpellcheckerLanguages = 'SpellcheckerLanguages',
|
|
}
|
|
|
|
const Separator: MenuItemConstructorOptions = {
|
|
type: 'separator',
|
|
}
|
|
|
|
export function buildContextMenu(webContents: WebContents, params: ContextMenuParams): Menu {
|
|
if (!params.isEditable) {
|
|
return Menu.buildFromTemplate([
|
|
{
|
|
role: 'copy',
|
|
},
|
|
])
|
|
}
|
|
|
|
return Menu.buildFromTemplate([
|
|
...suggestionsMenu(params.selectionText, params.misspelledWord, params.dictionarySuggestions, webContents),
|
|
Separator,
|
|
{
|
|
role: 'undo',
|
|
},
|
|
{
|
|
role: 'redo',
|
|
},
|
|
Separator,
|
|
{
|
|
role: 'cut',
|
|
},
|
|
{
|
|
role: 'copy',
|
|
},
|
|
{
|
|
role: 'paste',
|
|
},
|
|
{
|
|
role: 'pasteAndMatchStyle',
|
|
},
|
|
{
|
|
role: 'selectAll',
|
|
},
|
|
])
|
|
}
|
|
|
|
function suggestionsMenu(
|
|
selection: string,
|
|
misspelledWord: string,
|
|
suggestions: string[],
|
|
webContents: WebContents,
|
|
): MenuItemConstructorOptions[] {
|
|
if (misspelledWord.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const learnSpelling = {
|
|
label: contextMenu().learnSpelling,
|
|
click() {
|
|
webContents.session.addWordToSpellCheckerDictionary(misspelledWord)
|
|
},
|
|
}
|
|
|
|
if (suggestions.length === 0) {
|
|
return [
|
|
{
|
|
label: contextMenu().noSuggestions,
|
|
enabled: false,
|
|
},
|
|
Separator,
|
|
learnSpelling,
|
|
]
|
|
}
|
|
|
|
return [
|
|
...suggestions.map((suggestion) => ({
|
|
label: suggestion,
|
|
click() {
|
|
webContents.replaceMisspelling(suggestion)
|
|
},
|
|
})),
|
|
Separator,
|
|
learnSpelling,
|
|
]
|
|
}
|
|
|
|
export function createMenuManager({
|
|
window,
|
|
appState,
|
|
backupsManager,
|
|
trayManager,
|
|
store,
|
|
spellcheckerManager,
|
|
}: {
|
|
window: Electron.BrowserWindow
|
|
appState: AppState
|
|
backupsManager: BackupsManagerInterface
|
|
trayManager: TrayManager
|
|
store: Store
|
|
spellcheckerManager?: SpellcheckerManager
|
|
}): MenuManagerInterface {
|
|
let menu: Menu
|
|
|
|
if (isTesting()) {
|
|
// eslint-disable-next-line no-var
|
|
var hasReloaded = false
|
|
// eslint-disable-next-line no-var
|
|
var hasReloadedTimeout: any
|
|
handleTestMessage(MessageType.AppMenuItems, () =>
|
|
menu.items.map((item) => ({
|
|
label: item.label,
|
|
role: item.role,
|
|
submenu: {
|
|
items: item.submenu?.items?.map((subItem) => ({
|
|
id: subItem.id,
|
|
label: subItem.label,
|
|
})),
|
|
},
|
|
})),
|
|
)
|
|
handleTestMessage(MessageType.ClickLanguage, (code) => {
|
|
menu.getMenuItemById(MessageType.ClickLanguage + code)!.click()
|
|
})
|
|
handleTestMessage(MessageType.HasReloadedMenu, () => hasReloaded)
|
|
}
|
|
|
|
function reload() {
|
|
if (isTesting()) {
|
|
// eslint-disable-next-line block-scoped-var
|
|
hasReloaded = true
|
|
// eslint-disable-next-line block-scoped-var
|
|
clearTimeout(hasReloadedTimeout)
|
|
// eslint-disable-next-line block-scoped-var
|
|
hasReloadedTimeout = setTimeout(() => {
|
|
// eslint-disable-next-line block-scoped-var
|
|
hasReloaded = false
|
|
}, 300)
|
|
}
|
|
|
|
menu = Menu.buildFromTemplate([
|
|
...(isMac() ? [macAppMenu(app.name)] : []),
|
|
editMenu(spellcheckerManager, reload),
|
|
viewMenu(window, store, reload),
|
|
windowMenu(store, trayManager, reload),
|
|
backupsMenu(backupsManager, reload),
|
|
updateMenu(window, appState),
|
|
...(isLinux() ? [keyringMenu(window, store)] : []),
|
|
helpMenu(window, shell),
|
|
])
|
|
Menu.setApplicationMenu(menu)
|
|
}
|
|
autorun(() => {
|
|
reload()
|
|
})
|
|
|
|
return {
|
|
reload,
|
|
popupMenu() {
|
|
if (isDev()) {
|
|
/** Check the state */
|
|
if (!menu) {
|
|
throw new Error('called popupMenu() before loading')
|
|
}
|
|
}
|
|
// eslint-disable-next-line no-unused-expressions
|
|
menu?.popup()
|
|
},
|
|
}
|
|
}
|
|
|
|
const enum Roles {
|
|
Undo = 'undo',
|
|
Redo = 'redo',
|
|
Cut = 'cut',
|
|
Copy = 'copy',
|
|
Paste = 'paste',
|
|
PasteAndMatchStyle = 'pasteAndMatchStyle',
|
|
SelectAll = 'selectAll',
|
|
Reload = 'reload',
|
|
ToggleDevTools = 'toggleDevTools',
|
|
ResetZoom = 'resetZoom',
|
|
ZoomIn = 'zoomIn',
|
|
ZoomOut = 'zoomOut',
|
|
ToggleFullScreen = 'togglefullscreen',
|
|
Window = 'window',
|
|
Minimize = 'minimize',
|
|
Close = 'close',
|
|
Help = 'help',
|
|
About = 'about',
|
|
Services = 'services',
|
|
Hide = 'hide',
|
|
HideOthers = 'hideOthers',
|
|
UnHide = 'unhide',
|
|
Quit = 'quit',
|
|
StartSeeking = 'startSpeaking',
|
|
StopSeeking = 'stopSpeaking',
|
|
Zoom = 'zoom',
|
|
Front = 'front',
|
|
}
|
|
|
|
const KeyCombinations = {
|
|
CmdOrCtrlW: 'CmdOrCtrl + W',
|
|
CmdOrCtrlM: 'CmdOrCtrl + M',
|
|
AltM: 'Alt + m',
|
|
}
|
|
|
|
const enum MenuItemTypes {
|
|
CheckBox = 'checkbox',
|
|
Radio = 'radio',
|
|
}
|
|
|
|
const Urls = {
|
|
Support: 'mailto:help@standardnotes.com',
|
|
Website: 'https://standardnotes.com',
|
|
GitHub: 'https://github.com/standardnotes',
|
|
Slack: 'https://standardnotes.com/slack',
|
|
Discord: 'https://standardnotes.com/discord',
|
|
Twitter: 'https://twitter.com/StandardNotes',
|
|
GitHubReleases: 'https://github.com/standardnotes/app/releases',
|
|
}
|
|
|
|
function macAppMenu(appName: string): MenuItemConstructorOptions {
|
|
return {
|
|
role: 'appMenu',
|
|
label: appName,
|
|
submenu: [
|
|
{
|
|
role: Roles.About,
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.Services,
|
|
submenu: [],
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.Hide,
|
|
},
|
|
{
|
|
role: Roles.HideOthers,
|
|
},
|
|
{
|
|
role: Roles.UnHide,
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.Quit,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function editMenu(spellcheckerManager: SpellcheckerManager | undefined, reload: () => any): MenuItemConstructorOptions {
|
|
if (isDev()) {
|
|
/** Check for invalid state */
|
|
if (!isMac() && spellcheckerManager === undefined) {
|
|
throw new Error('spellcheckerManager === undefined')
|
|
}
|
|
}
|
|
|
|
return {
|
|
role: 'editMenu',
|
|
label: str().edit,
|
|
submenu: [
|
|
{
|
|
role: Roles.Undo,
|
|
},
|
|
{
|
|
role: Roles.Redo,
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.Cut,
|
|
},
|
|
{
|
|
role: Roles.Copy,
|
|
},
|
|
{
|
|
role: Roles.Paste,
|
|
},
|
|
{
|
|
role: Roles.PasteAndMatchStyle,
|
|
},
|
|
{
|
|
role: Roles.SelectAll,
|
|
},
|
|
...(isMac() ? [Separator, macSpeechMenu()] : [spellcheckerMenu(spellcheckerManager!, reload)]),
|
|
],
|
|
}
|
|
}
|
|
|
|
function macSpeechMenu(): MenuItemConstructorOptions {
|
|
return {
|
|
label: str().speech,
|
|
submenu: [
|
|
{
|
|
role: Roles.StopSeeking,
|
|
},
|
|
{
|
|
role: Roles.StopSeeking,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function spellcheckerMenu(spellcheckerManager: SpellcheckerManager, reload: () => any): MenuItemConstructorOptions {
|
|
return {
|
|
id: MenuId.SpellcheckerLanguages,
|
|
label: str().spellcheckerLanguages,
|
|
submenu: spellcheckerManager.languages().map(
|
|
({ name, code, enabled }): MenuItemConstructorOptions => ({
|
|
...(isTesting() ? { id: MessageType.ClickLanguage + code } : {}),
|
|
label: name,
|
|
type: MenuItemTypes.CheckBox,
|
|
checked: enabled,
|
|
click: () => {
|
|
if (enabled) {
|
|
spellcheckerManager.removeLanguage(code)
|
|
} else {
|
|
spellcheckerManager.addLanguage(code)
|
|
}
|
|
reload()
|
|
},
|
|
}),
|
|
),
|
|
}
|
|
}
|
|
|
|
function viewMenu(window: Electron.BrowserWindow, store: Store, reload: () => any): MenuItemConstructorOptions {
|
|
return {
|
|
label: str().view,
|
|
submenu: [
|
|
{
|
|
role: Roles.Reload,
|
|
},
|
|
{
|
|
role: Roles.ToggleDevTools,
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.ResetZoom,
|
|
},
|
|
{
|
|
role: Roles.ZoomIn,
|
|
},
|
|
{
|
|
role: Roles.ZoomOut,
|
|
},
|
|
Separator,
|
|
{
|
|
role: Roles.ToggleFullScreen,
|
|
},
|
|
...(isMac() ? [] : [Separator, ...menuBarOptions(window, store, reload)]),
|
|
],
|
|
}
|
|
}
|
|
|
|
function menuBarOptions(window: Electron.BrowserWindow, store: Store, reload: () => any) {
|
|
const useSystemMenuBar = store.get(StoreKeys.UseSystemMenuBar)
|
|
let isMenuBarVisible = store.get(StoreKeys.MenuBarVisible)
|
|
window.setMenuBarVisibility(isMenuBarVisible)
|
|
return [
|
|
{
|
|
visible: !isMac() && useSystemMenuBar,
|
|
label: str().hideMenuBar,
|
|
accelerator: KeyCombinations.AltM,
|
|
click: () => {
|
|
isMenuBarVisible = !isMenuBarVisible
|
|
window.setMenuBarVisibility(isMenuBarVisible)
|
|
store.set(StoreKeys.MenuBarVisible, isMenuBarVisible)
|
|
},
|
|
},
|
|
{
|
|
label: str().useThemedMenuBar,
|
|
type: MenuItemTypes.CheckBox,
|
|
checked: !useSystemMenuBar,
|
|
click: () => {
|
|
store.set(StoreKeys.UseSystemMenuBar, !useSystemMenuBar)
|
|
reload()
|
|
void dialog.showMessageBox({
|
|
title: str().preferencesChanged.title,
|
|
message: str().preferencesChanged.message,
|
|
})
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
function windowMenu(store: Store, trayManager: TrayManager, reload: () => any): MenuItemConstructorOptions {
|
|
return {
|
|
role: Roles.Window,
|
|
submenu: [
|
|
{
|
|
role: Roles.Minimize,
|
|
},
|
|
{
|
|
role: Roles.Close,
|
|
},
|
|
Separator,
|
|
...(isMac() ? macWindowItems() : [minimizeToTrayItem(store, trayManager, reload)]),
|
|
],
|
|
}
|
|
}
|
|
|
|
function macWindowItems(): MenuItemConstructorOptions[] {
|
|
return [
|
|
{
|
|
label: str().close,
|
|
accelerator: KeyCombinations.CmdOrCtrlW,
|
|
role: Roles.Close,
|
|
},
|
|
{
|
|
label: str().minimize,
|
|
accelerator: KeyCombinations.CmdOrCtrlM,
|
|
role: Roles.Minimize,
|
|
},
|
|
{
|
|
label: str().zoom,
|
|
role: Roles.Zoom,
|
|
},
|
|
Separator,
|
|
{
|
|
label: str().bringAllToFront,
|
|
role: Roles.Front,
|
|
},
|
|
]
|
|
}
|
|
|
|
function minimizeToTrayItem(store: Store, trayManager: TrayManager, reload: () => any) {
|
|
const minimizeToTray = trayManager.shouldMinimizeToTray()
|
|
return {
|
|
label: str().minimizeToTrayOnClose,
|
|
type: MenuItemTypes.CheckBox,
|
|
checked: minimizeToTray,
|
|
click() {
|
|
store.set(StoreKeys.MinimizeToTray, !minimizeToTray)
|
|
if (trayManager.shouldMinimizeToTray()) {
|
|
trayManager.createTrayIcon()
|
|
} else {
|
|
trayManager.destroyTrayIcon()
|
|
}
|
|
reload()
|
|
},
|
|
}
|
|
}
|
|
|
|
function backupsMenu(archiveManager: BackupsManagerInterface, reload: () => any) {
|
|
return {
|
|
label: str().backups,
|
|
submenu: [
|
|
{
|
|
label: archiveManager.backupsAreEnabled ? str().disableAutomaticBackups : str().enableAutomaticBackups,
|
|
click() {
|
|
archiveManager.toggleBackupsStatus()
|
|
reload()
|
|
},
|
|
},
|
|
Separator,
|
|
{
|
|
label: str().changeBackupsLocation,
|
|
click() {
|
|
archiveManager.changeBackupsLocation()
|
|
},
|
|
},
|
|
{
|
|
label: str().openBackupsLocation,
|
|
click() {
|
|
void shell.openPath(archiveManager.backupsLocation)
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function updateMenu(window: BrowserWindow, appState: AppState) {
|
|
const updateState = appState.updates
|
|
let label
|
|
if (updateState.checkingForUpdate) {
|
|
label = str().checkingForUpdate
|
|
} else if (updateState.updateNeeded) {
|
|
label = str().updateAvailable
|
|
} else {
|
|
label = str().updates
|
|
}
|
|
const submenu: MenuItemConstructorOptions[] = []
|
|
const structure = { label, submenu }
|
|
|
|
if (autoUpdatingAvailable) {
|
|
if (updateState.autoUpdateDownloaded && updateState.latestVersion) {
|
|
submenu.push({
|
|
label: str().installPendingUpdate(updateState.latestVersion),
|
|
click() {
|
|
void showUpdateInstallationDialog(window, appState)
|
|
},
|
|
})
|
|
}
|
|
|
|
submenu.push({
|
|
type: 'checkbox',
|
|
checked: updateState.enableAutoUpdate,
|
|
label: str().enableAutomaticUpdates,
|
|
click() {
|
|
updateState.toggleAutoUpdate()
|
|
},
|
|
})
|
|
|
|
submenu.push(Separator)
|
|
}
|
|
|
|
const latestVersion = updateState.latestVersion
|
|
|
|
submenu.push({
|
|
label: str().yourVersion(appState.version),
|
|
})
|
|
|
|
submenu.push({
|
|
label: latestVersion ? str().latestVersion(latestVersion) : str().releaseNotes,
|
|
click() {
|
|
openChangelog(updateState)
|
|
},
|
|
})
|
|
|
|
if (latestVersion) {
|
|
submenu.push({
|
|
label: str().viewReleaseNotes(latestVersion),
|
|
click() {
|
|
openChangelog(updateState)
|
|
},
|
|
})
|
|
}
|
|
|
|
if (autoUpdatingAvailable) {
|
|
submenu.push(Separator)
|
|
|
|
if (!updateState.checkingForUpdate) {
|
|
submenu.push({
|
|
label: str().checkForUpdate,
|
|
click() {
|
|
void checkForUpdate(appState, updateState, true)
|
|
},
|
|
})
|
|
}
|
|
|
|
if (updateState.lastCheck && !updateState.checkingForUpdate) {
|
|
submenu.push({
|
|
label: str().lastUpdateCheck(updateState.lastCheck),
|
|
})
|
|
}
|
|
}
|
|
|
|
return structure
|
|
}
|
|
|
|
function helpMenu(window: Electron.BrowserWindow, shell: Electron.Shell) {
|
|
return {
|
|
role: Roles.Help,
|
|
submenu: [
|
|
{
|
|
label: str().emailSupport,
|
|
click() {
|
|
void shell.openExternal(Urls.Support)
|
|
},
|
|
},
|
|
{
|
|
label: str().website,
|
|
click() {
|
|
void shell.openExternal(Urls.Website)
|
|
},
|
|
},
|
|
{
|
|
label: str().gitHub,
|
|
click() {
|
|
void shell.openExternal(Urls.GitHub)
|
|
},
|
|
},
|
|
{
|
|
label: str().discord,
|
|
click() {
|
|
void shell.openExternal(Urls.Discord)
|
|
},
|
|
},
|
|
{
|
|
label: str().slack,
|
|
click() {
|
|
void shell.openExternal(Urls.Slack)
|
|
},
|
|
},
|
|
{
|
|
label: str().twitter,
|
|
click() {
|
|
void shell.openExternal(Urls.Twitter)
|
|
},
|
|
},
|
|
Separator,
|
|
{
|
|
label: str().toggleErrorConsole,
|
|
click() {
|
|
window.webContents.toggleDevTools()
|
|
},
|
|
},
|
|
{
|
|
label: str().openDataDirectory,
|
|
click() {
|
|
const userDataPath = app.getPath('userData')
|
|
void shell.openPath(userDataPath)
|
|
},
|
|
},
|
|
{
|
|
label: str().clearCacheAndReload,
|
|
async click() {
|
|
await window.webContents.session.clearCache()
|
|
window.reload()
|
|
},
|
|
},
|
|
Separator,
|
|
{
|
|
label: str().version(app.getVersion()),
|
|
click() {
|
|
void shell.openExternal(Urls.GitHubReleases)
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/** It's called keyring on Ubuntu */
|
|
function keyringMenu(window: BrowserWindow, store: Store): MenuItemConstructorOptions {
|
|
const useNativeKeychain = store.get(StoreKeys.UseNativeKeychain)
|
|
return {
|
|
label: str().security.security,
|
|
submenu: [
|
|
{
|
|
enabled: !useNativeKeychain,
|
|
checked: useNativeKeychain ?? false,
|
|
type: 'checkbox',
|
|
label: str().security.useKeyringtoStorePassword,
|
|
async click() {
|
|
store.set(StoreKeys.UseNativeKeychain, true)
|
|
const { response } = await dialog.showMessageBox(window, {
|
|
message: str().security.enabledKeyringAccessMessage,
|
|
buttons: [str().security.enabledKeyringQuitNow, str().security.enabledKeyringPostpone],
|
|
})
|
|
if (response === 0) {
|
|
app.quit()
|
|
}
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|