diff --git a/src/cloud/components/Application.tsx b/src/cloud/components/Application.tsx index fbaba6f71f..76469d619d 100644 --- a/src/cloud/components/Application.tsx +++ b/src/cloud/components/Application.tsx @@ -9,7 +9,6 @@ import { import { isActiveElementAnInput, InputableDomElement } from '../lib/dom' import { useEffectOnce } from 'react-use' import { useSettings } from '../lib/stores/settings' -import { isPageSearchShortcut, isSidebarToggleShortcut } from '../lib/shortcuts' import { useSearch } from '../lib/stores/search' import AnnouncementAlert from './AnnouncementAlert' import { @@ -20,6 +19,8 @@ import { newDocEventEmitter, switchSpaceEventEmitter, SwitchSpaceEventDetails, + togglePreviewModeEventEmitter, + toggleSplitEditModeEventEmitter, } from '../lib/utils/events' import { usePathnameChangeEffect, useRouter } from '../lib/router' import { useNav } from '../lib/stores/nav' @@ -76,6 +77,7 @@ import { } from './molecules/PageSearch/InPageSearchPortal' import SidebarToggleButton from './SidebarToggleButton' import { getTeamURL } from '../lib/utils/patterns' +import { compareEventKeyWithKeymap } from '../../lib/keymap' interface ApplicationProps { className?: string @@ -140,7 +142,9 @@ const Application = ({ useEffect(() => { const handler = () => { - setShowFuzzyNavigation((prev) => !prev) + if (usingElectron) { + setShowFuzzyNavigation((prev) => !prev) + } } searchEventEmitter.listen(handler) return () => { @@ -229,11 +233,47 @@ const Application = ({ return } - if (isSidebarToggleShortcut(event)) { - preventKeyboardEventPropagation(event) - setPreferences((prev) => { - return { sidebarIsHidden: !prev.sidebarIsHidden } - }) + const keymap = preferences['keymap'] + if (keymap != null) { + const sidenavToggleShortcut = keymap.get('toggleSideNav') + if (compareEventKeyWithKeymap(sidenavToggleShortcut, event)) { + preventKeyboardEventPropagation(event) + setPreferences((prev) => { + return { sidebarIsHidden: !prev.sidebarIsHidden } + }) + } + } + + if (!usingElectron && keymap != null) { + const toggleGlobalSearchShortcut = keymap.get('toggleGlobalSearch') + if (compareEventKeyWithKeymap(toggleGlobalSearchShortcut, event)) { + preventKeyboardEventPropagation(event) + searchEventEmitter.dispatch() + } + } + + if (!usingElectron && keymap != null) { + const openPreferencesShortcut = keymap.get('openPreferences') + if (compareEventKeyWithKeymap(openPreferencesShortcut, event)) { + preventKeyboardEventPropagation(event) + openSettingsTab('preferences') + } + } + + if (!usingElectron && keymap != null) { + const togglePreviewModeShortcut = keymap.get('togglePreviewMode') + if (compareEventKeyWithKeymap(togglePreviewModeShortcut, event)) { + preventKeyboardEventPropagation(event) + togglePreviewModeEventEmitter.dispatch() + } + } + + if (!usingElectron && keymap != null) { + const toggleSplitEditModeShortcut = keymap.get('toggleSplitEditMode') + if (compareEventKeyWithKeymap(toggleSplitEditModeShortcut, event)) { + preventKeyboardEventPropagation(event) + toggleSplitEditModeEventEmitter.dispatch() + } } if (isSingleKeyEvent(event, 'escape') && isActiveElementAnInput()) { @@ -244,17 +284,20 @@ const Application = ({ ;(document.activeElement as InputableDomElement).blur() } - if (usingElectron && isPageSearchShortcut(event)) { - preventKeyboardEventPropagation(event) - if (showInPageSearch) { - setShowInPageSearch(false) - setShowInPageSearch(true) - } else { - setShowInPageSearch(true) + if (usingElectron && keymap != null) { + const inPageSearchShortcut = keymap.get('toggleInPageSearch') + if (compareEventKeyWithKeymap(inPageSearchShortcut, event)) { + preventKeyboardEventPropagation(event) + if (showInPageSearch) { + setShowInPageSearch(false) + setShowInPageSearch(true) + } else { + setShowInPageSearch(true) + } } } }, - [team, setPreferences, showInPageSearch] + [team, preferences, setPreferences, openSettingsTab, showInPageSearch] ) useGlobalKeyDownHandler(overrideBrowserCtrlsHandler) diff --git a/src/cloud/components/molecules/KeymapItemSection.tsx b/src/cloud/components/molecules/KeymapItemSection.tsx new file mode 100644 index 0000000000..d5a930da2f --- /dev/null +++ b/src/cloud/components/molecules/KeymapItemSection.tsx @@ -0,0 +1,222 @@ +import React, { + KeyboardEventHandler, + useCallback, + useMemo, + useRef, + useState, +} from 'react' +import { + getGenericShortcutString, + KeymapItemEditableProps, +} from '../../../lib/keymap' +import Button from '../../../design/components/atoms/Button' +import styled from '../../../design/lib/styled' +import { inputStyle } from '../../../design/lib/styled/styleFunctions' +import cc from 'classcat' +import { useToast } from '../../../design/lib/stores/toast' + +const invalidShortcutInputs = [' '] +const rejectedShortcutInputs = [' ', 'control', 'alt', 'shift', 'meta'] + +interface KeymapItemSectionProps { + keymapKey: string + currentKeymapItem?: KeymapItemEditableProps + updateKeymap: ( + key: string, + shortcutFirst: KeymapItemEditableProps, + shortcutSecond?: KeymapItemEditableProps + ) => Promise + removeKeymap: (key: string) => void + description: string +} + +const KeymapItemSection = ({ + keymapKey, + currentKeymapItem, + updateKeymap, + removeKeymap, + description, +}: KeymapItemSectionProps) => { + const [inputError, setInputError] = useState(false) + const [shortcutInputValue, setShortcutInputValue] = useState('') + const [changingShortcut, setChangingShortcut] = useState(false) + const [ + currentShortcut, + setCurrentShortcut, + ] = useState( + currentKeymapItem != null ? currentKeymapItem : null + ) + const [ + previousShortcut, + setPreviousShortcut, + ] = useState(null) + const shortcutInputRef = useRef(null) + + const { pushMessage } = useToast() + + const fetchInputShortcuts: KeyboardEventHandler = ( + event + ) => { + event.stopPropagation() + event.preventDefault() + if (invalidShortcutInputs.includes(event.key.toLowerCase())) { + setInputError(true) + return + } + + setInputError(false) + + const shortcut: KeymapItemEditableProps = { + key: event.key.toUpperCase(), + keycode: event.keyCode, + modifiers: { + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + }, + } + setCurrentShortcut(shortcut) + setShortcutInputValue(getGenericShortcutString(shortcut)) + } + + const applyKeymap = useCallback(() => { + if (currentShortcut == null) { + return + } + if (rejectedShortcutInputs.includes(currentShortcut.key.toLowerCase())) { + setInputError(true) + if (shortcutInputRef.current != null) { + shortcutInputRef.current.focus() + } + return + } + + updateKeymap(keymapKey, currentShortcut, undefined) + .then(() => { + setChangingShortcut(false) + setInputError(false) + }) + .catch(() => { + pushMessage({ + title: 'Keymap assignment failed', + description: 'Cannot assign to already assigned shortcut', + }) + setInputError(true) + }) + }, [currentShortcut, keymapKey, updateKeymap, pushMessage]) + + const toggleChangingShortcut = useCallback(() => { + if (changingShortcut) { + applyKeymap() + } else { + setChangingShortcut(true) + setPreviousShortcut(currentShortcut) + if (currentShortcut != null) { + setShortcutInputValue(getGenericShortcutString(currentShortcut)) + } + } + }, [applyKeymap, currentShortcut, changingShortcut]) + + const handleCancelKeymapChange = useCallback(() => { + setCurrentShortcut(previousShortcut) + setChangingShortcut(false) + setShortcutInputValue('') + setInputError(false) + }, [previousShortcut]) + + const handleRemoveKeymap = useCallback(() => { + setCurrentShortcut(null) + setPreviousShortcut(null) + setShortcutInputValue('') + removeKeymap(keymapKey) + }, [keymapKey, removeKeymap]) + + const shortcutString = useMemo(() => { + return currentShortcut != null && currentKeymapItem != null + ? getGenericShortcutString(currentKeymapItem) + : '' + }, [currentKeymapItem, currentShortcut]) + return ( + +
{description}
+ + {currentShortcut != null && currentKeymapItem != null && ( + {shortcutString} + )} + {changingShortcut && ( + undefined} + onKeyDown={fetchInputShortcuts} + /> + )} + + {changingShortcut && ( + + )} + + {currentShortcut != null && !changingShortcut && ( + + )} + +
+ ) +} + +const ShortcutItemStyle = styled.div` + min-width: 88px; + max-width: 120px; + height: 32px; + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + + background-color: ${({ theme }) => theme.colors.background.tertiary}; + color: ${({ theme }) => theme.colors.text.primary}; + + border: 1px solid ${({ theme }) => theme.colors.border.main}; + border-radius: 4px; +` + +const StyledInput = styled.input` + ${inputStyle}; + max-width: 120px; + min-width: 110px; + height: 1.3em; + + &.error { + border: 1px solid red; + } +` + +const KeymapItemSectionContainer = styled.div` + display: grid; + grid-template-columns: 45% minmax(55%, 400px); +` + +const KeymapItemInputSection = styled.div` + display: grid; + grid-auto-flow: column; + align-items: center; + + max-width: 380px; + justify-items: left; + + margin-right: auto; + + column-gap: 1em; +` + +export default KeymapItemSection diff --git a/src/cloud/components/settings/KeymapTab.tsx b/src/cloud/components/settings/KeymapTab.tsx new file mode 100644 index 0000000000..23a7dcda09 --- /dev/null +++ b/src/cloud/components/settings/KeymapTab.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { getGenericShortcutString, KeymapItem } from '../../../lib/keymap' +import Button from '../../../design/components/atoms/Button' +import KeymapItemSection from '../molecules/KeymapItemSection' +import styled from '../../../design/lib/styled' +import { usePreferences } from '../../lib/stores/preferences' +import Form from '../../../design/components/molecules/Form' +import { lngKeys } from '../../lib/i18n/types' +import FormRow from '../../../design/components/molecules/Form/templates/FormRow' +import SettingTabContent from '../../../design/components/organisms/Settings/atoms/SettingTabContent' + +const KeymapTab = () => { + const { + preferences, + updateKeymap, + removeKeymap, + resetKeymap, + } = usePreferences() + const { t } = useTranslation() + + const keymap = useMemo(() => { + const keymap = preferences['keymap'] + return [...keymap.entries()] + }, [preferences]) + + const getKeymapItemSectionKey = useCallback((keymapItem: KeymapItem) => { + if (keymapItem.shortcutMainStroke == null) { + return keymapItem.description + } else { + return getGenericShortcutString(keymapItem.shortcutMainStroke) + } + }, []) + + return ( + + {keymap != null && + keymap.map((keymapEntry: [string, KeymapItem]) => { + return ( + + ) + })} + + ), + }, + ], + }, + ]} + > + + + + + + + + + } + /> + ) +} + +const KeymapItemList = styled.div` + display: grid; + grid-template-rows: auto; + row-gap: 0.5em; +` + +const KeymapHeaderSection = styled.div` + display: grid; + grid-template-columns: auto auto; +` + +const SectionResetKeymap = styled.div` + margin-left: auto; + align-self: center; +` + +export default KeymapTab diff --git a/src/cloud/components/settings/SettingsComponent.tsx b/src/cloud/components/settings/SettingsComponent.tsx index 52087fd5f6..cfdaad04b6 100644 --- a/src/cloud/components/settings/SettingsComponent.tsx +++ b/src/cloud/components/settings/SettingsComponent.tsx @@ -45,6 +45,7 @@ import AttachmentsTab from './AttachmentsTab' import ImportTab from './ImportTab' import TeamSubLimit from './TeamSubLimit' import { ExternalLink } from '../../../design/components/atoms/Link' +import KeymapTab from './KeymapTab' const SettingsComponent = () => { const { t } = useTranslation() @@ -107,6 +108,8 @@ const SettingsComponent = () => { return case 'preferences': return + case 'keymap': + return case 'teamInfo': return case 'teamMembers': @@ -210,6 +213,12 @@ const SettingsComponent = () => { id='settings-personalInfoTab-btn' onClick={() => openSettingsTab('personalInfo')} /> + openSettingsTab('keymap')} + /> ) { - localLiteStorage.setItem(preferencesKey, JSON.stringify(preferences)) -} +import { + createCodemirrorTypeKeymap, + defaultKeymap, + findExistingShortcut, + getMenuAcceleratorForKeymapItem, + isMenuKeymap, + KeymapItem, + KeymapItemEditableProps, +} from '../../../../lib/keymap' +import { useElectron } from '../electron' const basePreferences: Preferences = { folderSortingOrder: 'Latest Updated', @@ -30,13 +36,41 @@ const basePreferences: Preferences = { ), version: 1, docPropertiesAreHidden: false, + keymap: new Map(), +} + +function replacer(_key: string, value: any) { + if (value instanceof Map && value.size > 0) { + return { + dataType: 'Map', + value: [...value.entries()], + } + } else { + return value + } +} + +function reviver(_key: string, value: any) { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'Map') { + return new Map(value.value) + } + } + return value +} + +function savePreferencesToLocalStorage(preferences: Partial) { + localLiteStorage.setItem( + preferencesKey, + JSON.stringify(preferences, replacer) + ) } function getExistingPreferences() { try { const stringifiedPreferences = localLiteStorage.getItem(preferencesKey) if (stringifiedPreferences == null) return - const existingPreferences = JSON.parse(stringifiedPreferences) + const existingPreferences = JSON.parse(stringifiedPreferences, reviver) if (existingPreferences.version == null) { existingPreferences.version = 1 @@ -71,16 +105,25 @@ function usePreferencesStore() { const [preferences, setPreferences] = useSetState>( initialPreference ) + const { sendToElectron, usingElectron } = useElectron() const hoverOffTimeoutRef = useRef() - useEffect(() => { - savePreferencesToLocalStorage(preferences) - }, [preferences]) - const mergedPreferences = useMemo(() => { + const preferencesKeymap = preferences['keymap'] + const keymap = basePreferences['keymap'] + try { + if (preferencesKeymap != null) { + preferencesKeymap.forEach((value, key) => { + keymap.set(key, value) + }) + } + } catch (e) { + console.warn('Corrupted storage, preferences keymap was null!') + } return { ...basePreferences, ...preferences, + keymap: keymap, } }, [preferences]) @@ -120,6 +163,174 @@ function usePreferencesStore() { setPreferences({ sidebarIsHovered: true }) }, [setPreferences]) + const keymap = mergedPreferences['keymap'] + const getAcceleratorTypeKeymap = useCallback( + (key: string) => { + if (keymap == null) { + return '' + } + const keymapItem = keymap.get(key) + if (keymapItem == null) { + return '' + } + return getMenuAcceleratorForKeymapItem(keymapItem) + }, + [keymap] + ) + + const getCodemirrorTypeKeymap = useCallback( + (key: string) => { + if (keymap == null) { + return null + } + const keymapItem = keymap.get(key) + if (keymapItem == null || keymapItem.shortcutMainStroke == null) { + return null + } + let keymapString = createCodemirrorTypeKeymap( + keymapItem.shortcutMainStroke + ) + if (keymapItem.shortcutSecondStroke != null) { + keymapString += + ' ' + createCodemirrorTypeKeymap(keymapItem.shortcutSecondStroke) + } + return keymapString + }, + [keymap] + ) + + const updateKeymap = useCallback( + ( + key: string, + firstShortcut: KeymapItemEditableProps, + secondShortcut?: KeymapItemEditableProps + ): Promise => { + if (keymap == null) { + return Promise.reject('No keymap available') + } + if (findExistingShortcut(key, firstShortcut, keymap)) { + return Promise.reject('Existing keymap with the same shortcut') + } + const keymapItem = keymap.get(key) + if (keymapItem == null) { + return Promise.reject(`No such keymap to set for key: ${key}`) + } + keymap.set(key, { + description: keymapItem.description, + isMenuType: keymapItem.isMenuType, + shortcutMainStroke: { + ...keymapItem.shortcutMainStroke, + ...firstShortcut, + }, + shortcutSecondStroke: + secondShortcut != null + ? { + ...keymapItem.shortcutSecondStroke, + ...secondShortcut, + } + : undefined, + }) + + setPreferences((preferences) => { + return { + ...preferences, + keymap: keymap, + } + }) + + if (isMenuKeymap(keymapItem)) { + if (usingElectron) { + sendToElectron('menuAcceleratorChanged', [ + key, + getMenuAcceleratorForKeymapItem(keymapItem), + ]) + } + } + return Promise.resolve() + }, + [keymap, sendToElectron, setPreferences, usingElectron] + ) + + const removeKeymap = useCallback( + (key) => { + if (keymap == null) { + return false + } + const keymapItem = keymap.get(key) + if (keymapItem == null) { + return false + } + keymap.set(key, { + ...keymapItem, + shortcutMainStroke: undefined, + shortcutSecondStroke: undefined, + }) + setPreferences((preferences) => { + return { + ...preferences, + keymap: keymap, + } + }) + + if (isMenuKeymap(keymapItem)) { + if (usingElectron) { + sendToElectron('menuAcceleratorChanged', [key, null]) + } + } + return true + }, + [keymap, sendToElectron, setPreferences, usingElectron] + ) + + const loadKeymaps = useCallback(() => { + const keymap = mergedPreferences['keymap'] + for (const [key, keymapItem] of keymap) { + if (isMenuKeymap(keymapItem)) { + if (usingElectron) { + sendToElectron('menuAcceleratorChanged', [ + key, + getMenuAcceleratorForKeymapItem(keymapItem), + ]) + } + } + } + + // add new keymaps to preferences if weren't available before + let addedKeymap = false + for (const [key, keymapItem] of defaultKeymap) { + if (!keymap.has(key)) { + keymap.set(key, keymapItem) + addedKeymap = true + } + } + if (addedKeymap) { + setPreferences((preferences) => { + return { + ...preferences, + keymap: keymap, + } + }) + } + }, [mergedPreferences, sendToElectron, setPreferences, usingElectron]) + + const resetKeymap = useCallback(() => { + keymap.clear() + for (const [key, keymapItem] of defaultKeymap) { + keymap.set(key, keymapItem) + } + setPreferences((preferences) => { + return { + ...preferences, + keymap: defaultKeymap, + } + }) + }, [keymap, setPreferences]) + + useEffect(() => { + loadKeymaps() + savePreferencesToLocalStorage(preferences) + }, [loadKeymaps, mergedPreferences, preferences, resetKeymap]) + return { preferences: mergedPreferences, setPreferences, @@ -127,6 +338,11 @@ function usePreferencesStore() { hoverSidebarOff, hoverSidebarOn, topBarPaddingLeft, + updateKeymap, + removeKeymap, + resetKeymap, + getCodemirrorTypeKeymap, + getAcceleratorTypeKeymap, } } diff --git a/src/cloud/lib/stores/preferences/types.ts b/src/cloud/lib/stores/preferences/types.ts index 15d504b269..5465f6fb08 100644 --- a/src/cloud/lib/stores/preferences/types.ts +++ b/src/cloud/lib/stores/preferences/types.ts @@ -1,6 +1,7 @@ import { SidebarState } from '../../../../design/lib/sidebar' import { SidebarTreeSortingOrder } from '../../../../design/lib/sidebar' import { DocStatus } from '../../../interfaces/db/doc' +import { KeymapItem } from '../../../../lib/keymap' export type LayoutMode = 'split' | 'preview' | 'editor' @@ -19,4 +20,5 @@ export interface Preferences { version?: number docStatusDisplayed: DocStatus[] docPropertiesAreHidden: boolean + keymap: Map } diff --git a/src/cloud/lib/stores/settings/store.ts b/src/cloud/lib/stores/settings/store.ts index 8e28b9abed..96253e3cc3 100644 --- a/src/cloud/lib/stores/settings/store.ts +++ b/src/cloud/lib/stores/settings/store.ts @@ -33,6 +33,7 @@ export const baseUserSettings: UserSettings = { export type SettingsTab = | 'personalInfo' | 'preferences' + | 'keymap' | 'teamInfo' | 'teamMembers' | 'integrations' diff --git a/src/components/atoms/BoostHubWebview.tsx b/src/components/atoms/BoostHubWebview.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/electron/index.ts b/src/electron/index.ts index 2ce3a5f907..4d2b721747 100644 --- a/src/electron/index.ts +++ b/src/electron/index.ts @@ -18,6 +18,20 @@ const mac = process.platform === 'darwin' let ready = false const singleInstance = app.requestSingleInstanceLock() + +export const keymap = new Map([ + ['toggleGlobalSearch', 'CmdOrCtrl + P'], + ['toggleSplitEditMode', 'CmdOrCtrl + \\'], + ['togglePreviewMode', 'CmdOrCtrl + E'], + ['editorSaveAs', 'CmdOrCtrl + S'], + ['createNewDoc', 'CmdOrCtrl + N'], + ['createNewFolder', 'CmdOrCtrl + Shift + N'], + ['resetZoom', 'CmdOrCtrl + 0'], + ['zoomOut', 'CmdOrCtrl + -'], + ['zoomIn', 'CmdOrCtrl + Plus'], + ['openPreferences', 'CmdOrCtrl + ,'], +]) + if (!singleInstance) { app.quit() } else { @@ -82,7 +96,7 @@ app.on('ready', () => { )}` ) - applyMenuTemplate(getTemplateFromKeymap()) + applyMenuTemplate(getTemplateFromKeymap(keymap)) // multiple windows support ipcMain.on('new-window-event', (args: any) => { @@ -121,4 +135,15 @@ app.on('ready', () => { window.webContents.send('open-boostnote-url', url) }) }) + + ipcMain.on('menuAcceleratorChanged', (_, args) => { + if (args.length != 2) { + return + } + const menuItemId = args[0] + const newAcceleratorShortcut = args[1] == null ? undefined : args[1] + + keymap.set(menuItemId, newAcceleratorShortcut) + applyMenuTemplate(getTemplateFromKeymap(keymap)) + }) }) diff --git a/src/electron/menu.ts b/src/electron/menu.ts index 574ed9ab3e..4f5097a5b6 100644 --- a/src/electron/menu.ts +++ b/src/electron/menu.ts @@ -12,16 +12,18 @@ import { electronFrontendUrl } from './consts' const mac = process.platform === 'darwin' -export function getTemplateFromKeymap(): MenuItemConstructorOptions[] { +export function getTemplateFromKeymap( + keymap: Map +): MenuItemConstructorOptions[] { const menu: MenuItemConstructorOptions[] = [] if (mac) { menu.push(getMacRootMenu()) } - menu.push(getFileMenu()) + menu.push(getFileMenu(keymap)) menu.push(getEditMenu()) - menu.push(getViewMenu()) + menu.push(getViewMenu(keymap)) menu.push(getWindowMenu()) menu.push(getCommunityMenu()) menu.push({ @@ -73,7 +75,7 @@ function getMacRootMenu(): MenuItemConstructorOptions { } } -function getFileMenu(): MenuItemConstructorOptions { +function getFileMenu(keymap: Map): MenuItemConstructorOptions { const submenuItems: MenuItemConstructorOptions[] = mac ? [ { @@ -89,14 +91,14 @@ function getFileMenu(): MenuItemConstructorOptions { type: 'normal', label: 'New Document', click: createEmitIpcMenuItemHandler('new-doc'), - accelerator: 'Cmd + N', + accelerator: keymap.get('createNewDoc'), }, { type: 'separator' }, { type: 'normal', label: 'Save As', click: createEmitIpcMenuItemHandler('save-as'), - accelerator: 'Cmd + S', + accelerator: keymap.get('editorSaveAs'), }, { type: 'separator' }, { role: 'close' }, @@ -115,14 +117,14 @@ function getFileMenu(): MenuItemConstructorOptions { type: 'normal', label: 'New Document', click: createEmitIpcMenuItemHandler('new-doc'), - accelerator: 'Ctrl + N', + accelerator: keymap.get('createNewDoc'), }, { type: 'separator' }, { type: 'normal', label: 'Save As', click: createEmitIpcMenuItemHandler('save-as'), - accelerator: 'Ctrl + S', + accelerator: keymap.get('editorSaveAs'), }, { type: 'separator' }, { @@ -139,7 +141,7 @@ function getFileMenu(): MenuItemConstructorOptions { { type: 'separator' }, { label: 'Preferences', - accelerator: 'Ctrl+,', + accelerator: keymap.get('openPreferences'), click: createEmitIpcMenuItemHandler('toggle-settings'), }, { type: 'separator' }, @@ -188,7 +190,7 @@ function getEditMenu(): MenuItemConstructorOptions { } } -function getViewMenu(): MenuItemConstructorOptions { +function getViewMenu(keymap: Map): MenuItemConstructorOptions { const submenuItems: MenuItemConstructorOptions[] = [ { type: 'submenu', @@ -315,9 +317,9 @@ function getViewMenu(): MenuItemConstructorOptions { }, { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, + { role: 'resetZoom', accelerator: keymap.get('resetZoom') }, + { role: 'zoomIn', accelerator: keymap.get('zoomIn') }, + { role: 'zoomOut', accelerator: keymap.get('zoomOut') }, { type: 'separator' }, { role: 'togglefullscreen' }, ] diff --git a/src/electron/windows.ts b/src/electron/windows.ts index 28ea042bc5..aafd130efb 100644 --- a/src/electron/windows.ts +++ b/src/electron/windows.ts @@ -9,6 +9,7 @@ import { import path from 'path' import { dev } from './consts' import { getTemplateFromKeymap } from './menu' +import { keymap } from './index' const windows = new Set() @@ -54,7 +55,7 @@ export function createAWindow( window.show() }) - applyMenuTemplate(getTemplateFromKeymap()) + applyMenuTemplate(getTemplateFromKeymap(keymap)) if (MAC) { window.on('close', (event) => { diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts new file mode 100644 index 0000000000..56acebf855 --- /dev/null +++ b/src/lib/keymap.ts @@ -0,0 +1,479 @@ +import { osName } from '../design/lib/platform' + +interface ModifierItem { + ctrl?: boolean + shift?: boolean + alt?: boolean + meta?: boolean +} + +export interface KeymapItemEditableProps { + key: string + keycode: number + modifiers?: ModifierItem +} + +export interface KeymapItem { + shortcutMainStroke?: KeymapItemEditableProps + shortcutSecondStroke?: KeymapItemEditableProps + description: string + isMenuType?: boolean +} + +export const defaultKeymap = new Map([ + [ + 'createNewDoc', + { + shortcutMainStroke: { + key: 'N', + keycode: 78, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Create new document', + isMenuType: true, + }, + ], + [ + 'createNewFolder', + { + shortcutMainStroke: { + key: 'N', + keycode: 78, + modifiers: + osName === 'macos' + ? { meta: true, shift: true } + : { + ctrl: true, + shift: true, + }, + }, + description: 'Create new folder', + isMenuType: true, + }, + ], + [ + 'toggleSideNav', + { + shortcutMainStroke: { + key: '0', + keycode: 48, + modifiers: + osName === 'macos' + ? { meta: true, shift: true } + : { + ctrl: true, + shift: true, + }, + }, + description: 'Toggle side nav', + }, + ], + [ + 'toggleGlobalSearch', + { + shortcutMainStroke: { + key: 'P', + keycode: 80, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Toggle global search modal dialog', + isMenuType: true, + }, + ], + [ + 'toggleInPageSearch', + { + shortcutMainStroke: { + key: 'F', + keycode: 70, + modifiers: + osName === 'macos' + ? { meta: true, shift: true } + : { + ctrl: true, + shift: true, + }, + }, + description: 'Toggle in-page search modal dialog', + }, + ], + // todo: [komediruzecki-2021-11-7] enable once implemented + // [ + // 'toggleLocalSearch', + // { + // shortcutMainStroke: { + // key: 'F', + // keycode: 70, + // modifiers: + // osName === 'macos' + // ? { meta: true } + // : { + // ctrl: true, + // }, + // }, + // description: 'Toggle local editor search modal dialog', + // }, + // ], + // [ + // 'toggleLocalReplace', + // { + // shortcutMainStroke: { + // key: 'F', + // keycode: 70, + // modifiers: + // osName === 'macos' + // ? { meta: true, alt: true } + // : { + // ctrl: true, + // alt: true, + // }, + // }, + // description: 'Toggle local editor replace modal dialog', + // }, + // ], + // todo: [komediruzecki-2021-11-7] enable once implemented + // [ + // 'editorSaveAs', + // { + // shortcutMainStroke: { + // key: 'S', + // keycode: 83, + // modifiers: + // osName === 'macos' + // ? { meta: true } + // : { + // ctrl: true, + // }, + // }, + // description: 'Export open document (save as)', + // isMenuType: true, + // }, + // ], + [ + 'togglePreviewMode', + { + shortcutMainStroke: { + key: 'E', + keycode: 69, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Toggle preview mode in editor', + isMenuType: true, + }, + ], + [ + 'toggleSplitEditMode', + { + shortcutMainStroke: { + key: '\\', + keycode: 220, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Toggles split edit mode in editor', + isMenuType: true, + }, + ], + [ + 'zoomIn', + { + shortcutMainStroke: { + key: '+', + keycode: 61, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Zoom in window', + isMenuType: true, + }, + ], + [ + 'zoomOut', + { + shortcutMainStroke: { + key: '-', + keycode: 173, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Zoom out window', + isMenuType: true, + }, + ], + [ + 'resetZoom', + { + shortcutMainStroke: { + key: '0', + keycode: 48, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Reset window zoom', + isMenuType: true, + }, + ], + // todo: [komediruzecki-2021-11-7] Enable once implemented + // [ + // 'focusEditor', + // { + // shortcutMainStroke: { + // key: 'J', + // keycode: 74, + // modifiers: + // osName === 'macos' + // ? { meta: true } + // : { + // ctrl: true, + // }, + // }, + // description: 'Focus document editor', + // isMenuType: true, + // }, + // ], + // [ + // 'focusDocTitle', + // { + // shortcutMainStroke: { + // key: 'J', + // keycode: 74, + // modifiers: + // osName === 'macos' + // ? { meta: true, shift: true } + // : { + // ctrl: true, + // shift: true, + // }, + // }, + // description: 'Focus document title', + // isMenuType: true, + // }, + // ], + [ + 'openPreferences', + { + shortcutMainStroke: { + key: ',', + keycode: 188, + modifiers: + osName === 'macos' + ? { meta: true } + : { + ctrl: true, + }, + }, + description: 'Open Preferences', + isMenuType: true, + }, + ], + // todo: [komediruzecki-2021-11-7] Enable when feature implemented + // initially un-assigned list + // [ + // 'insertLocaleDateString', + // { + // description: 'Insert locale date', + // isMenuType: false, + // }, + // ], + // [ + // 'insertDateAndTimeString', + // { + // description: 'Insert date and time', + // isMenuType: false, + // }, + // ], +]) + +export function compareEventKeyWithKeymap( + keymapProps: KeymapItem | undefined, + event: KeyboardEvent +) { + if (keymapProps == null || keymapProps.shortcutMainStroke == null) { + return + } + const eventProps: KeymapItemEditableProps = { + key: event.key.toUpperCase(), + modifiers: { + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + meta: event.metaKey, + }, + keycode: event.keyCode, + } + return areShortcutsEqual(keymapProps.shortcutMainStroke, eventProps) +} + +export function createCodemirrorTypeKeymap( + keymapProps: KeymapItemEditableProps +) { + let keymapString = '' + if (keymapProps.modifiers != null) { + if (keymapProps.modifiers.shift != null) { + keymapString += keymapProps.modifiers.shift ? 'Shift-' : '' + } + if (keymapProps.modifiers.meta != null) { + keymapString += keymapProps.modifiers.meta ? 'Cmd-' : '' + } + if (keymapProps.modifiers.ctrl != null) { + keymapString += keymapProps.modifiers.ctrl ? 'Ctrl-' : '' + } + if (keymapProps.modifiers.alt != null) { + keymapString += keymapProps.modifiers.alt ? 'Alt-' : '' + } + } + + const keyLowercase = keymapProps.key.toLowerCase() + if ( + keyLowercase != 'control' && + keyLowercase != 'shift' && + keyLowercase != 'alt' && + keyLowercase != 'meta' + ) { + keymapString += keymapProps.key + } + return keymapString +} + +function convertNullToFalse(value?: boolean) { + return value == null ? false : value +} + +function areShortcutsEqual( + first: KeymapItemEditableProps, + second: KeymapItemEditableProps +) { + if (first.keycode != second.keycode || first.key != second.key) { + return false + } + if (first.modifiers && second.modifiers) { + if ( + convertNullToFalse(first.modifiers.ctrl) != + convertNullToFalse(second.modifiers.ctrl) + ) { + return false + } + + if ( + convertNullToFalse(first.modifiers.shift) != + convertNullToFalse(second.modifiers.shift) + ) { + return false + } + if ( + convertNullToFalse(first.modifiers.alt) != + convertNullToFalse(second.modifiers.alt) + ) { + return false + } + } else { + if ( + (first.modifiers == null && second.modifiers != null) || + (first.modifiers != null && second.modifiers == null) + ) { + return false + } + } + + return true +} + +export function findExistingShortcut( + shortcutKey: string, + shortcut: KeymapItemEditableProps, + keymap: Map +) { + for (const [key, keymapShortcut] of keymap) { + if (key == shortcutKey) { + continue + } + if (keymapShortcut.shortcutMainStroke != null) { + if (areShortcutsEqual(shortcut, keymapShortcut.shortcutMainStroke)) { + return true + } + } + } + return false +} + +export function isMenuKeymap(keymapItem: KeymapItem): boolean { + return convertNullToFalse(keymapItem.isMenuType) +} + +export function getGenericShortcutString( + shortcut: KeymapItemEditableProps +): string { + return createCodemirrorTypeKeymap(shortcut) +} + +export function getMenuAcceleratorKeymapKey(key: string): string { + switch (key) { + default: + return key + case '+': + return 'Plus' + case ' ': + return 'Space' + case '\t': + return 'Tab' + } +} + +export function getMenuAcceleratorForKeymapItem( + keymapItem: KeymapItem +): string { + const keymapProps = keymapItem.shortcutMainStroke + if (keymapProps == null) { + return '' + } + let keymapString = '' + if (keymapProps.modifiers != null) { + if (keymapProps.modifiers.ctrl != null) { + keymapString += keymapProps.modifiers.ctrl ? 'Ctrl + ' : '' + } + if (keymapProps.modifiers.shift != null) { + keymapString += keymapProps.modifiers.shift ? 'Shift + ' : '' + } + if (keymapProps.modifiers.alt != null) { + keymapString += keymapProps.modifiers.alt ? 'Alt + ' : '' + } + if (keymapProps.modifiers.meta != null) { + keymapString += keymapProps.modifiers.meta ? 'Meta + ' : '' + } + } + + const menuKeymapKey = getMenuAcceleratorKeymapKey(keymapProps.key) + keymapString += menuKeymapKey + return keymapString +}