diff --git a/.eslintignore b/.eslintignore index 96158845f..d79232c44 100644 --- a/.eslintignore +++ b/.eslintignore @@ -61,9 +61,39 @@ Modules/TinyMCE/IconPack/postinstall.js Modules/TinyMCE/langs/ # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD +ElectronClient/commands/focusElement.js +ElectronClient/commands/startExternalEditing.js +ElectronClient/commands/stopExternalEditing.js ElectronClient/global.d.js +ElectronClient/gui/ErrorBoundary.js +ElectronClient/gui/Header/commands/focusSearch.js +ElectronClient/gui/MainScreen/commands/editAlarm.js +ElectronClient/gui/MainScreen/commands/exportPdf.js +ElectronClient/gui/MainScreen/commands/hideModalMessage.js +ElectronClient/gui/MainScreen/commands/moveToFolder.js +ElectronClient/gui/MainScreen/commands/newNote.js +ElectronClient/gui/MainScreen/commands/newNotebook.js +ElectronClient/gui/MainScreen/commands/newTodo.js +ElectronClient/gui/MainScreen/commands/print.js +ElectronClient/gui/MainScreen/commands/renameFolder.js +ElectronClient/gui/MainScreen/commands/renameTag.js +ElectronClient/gui/MainScreen/commands/search.js +ElectronClient/gui/MainScreen/commands/selectTemplate.js +ElectronClient/gui/MainScreen/commands/setTags.js +ElectronClient/gui/MainScreen/commands/showModalMessage.js +ElectronClient/gui/MainScreen/commands/showNoteContentProperties.js +ElectronClient/gui/MainScreen/commands/showNoteProperties.js +ElectronClient/gui/MainScreen/commands/showShareNoteDialog.js +ElectronClient/gui/MainScreen/commands/toggleNoteList.js +ElectronClient/gui/MainScreen/commands/toggleSidebar.js +ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.js ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/NoteContentPropertiesDialog.js +ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.js +ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.js +ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.js +ElectronClient/gui/NoteEditor/commands/showLocalSearch.js +ElectronClient/gui/NoteEditor/commands/showRevisions.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js @@ -95,12 +125,18 @@ ElectronClient/gui/NoteEditor/utils/useMessageHandler.js ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js +ElectronClient/gui/NoteList/commands/focusElementNoteList.js ElectronClient/gui/NoteListItem.js ElectronClient/gui/NoteToolbar/NoteToolbar.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js +ElectronClient/gui/SideBar/commands/focusElementSideBar.js ReactNativeClient/lib/AsyncActionQueue.js ReactNativeClient/lib/checkPermissions.js +ReactNativeClient/lib/commands/historyBackward.js +ReactNativeClient/lib/commands/historyForward.js +ReactNativeClient/lib/commands/synchronize.js +ReactNativeClient/lib/hooks/useEffectDebugger.js ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js ReactNativeClient/lib/hooks/usePrevious.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js @@ -108,6 +144,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/services/CommandService.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.mobile.js diff --git a/.gitignore b/.gitignore index d95cdf5c9..b08e40c5a 100644 --- a/.gitignore +++ b/.gitignore @@ -51,9 +51,39 @@ Tools/commit_hook.txt *.map # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD +ElectronClient/commands/focusElement.js +ElectronClient/commands/startExternalEditing.js +ElectronClient/commands/stopExternalEditing.js ElectronClient/global.d.js +ElectronClient/gui/ErrorBoundary.js +ElectronClient/gui/Header/commands/focusSearch.js +ElectronClient/gui/MainScreen/commands/editAlarm.js +ElectronClient/gui/MainScreen/commands/exportPdf.js +ElectronClient/gui/MainScreen/commands/hideModalMessage.js +ElectronClient/gui/MainScreen/commands/moveToFolder.js +ElectronClient/gui/MainScreen/commands/newNote.js +ElectronClient/gui/MainScreen/commands/newNotebook.js +ElectronClient/gui/MainScreen/commands/newTodo.js +ElectronClient/gui/MainScreen/commands/print.js +ElectronClient/gui/MainScreen/commands/renameFolder.js +ElectronClient/gui/MainScreen/commands/renameTag.js +ElectronClient/gui/MainScreen/commands/search.js +ElectronClient/gui/MainScreen/commands/selectTemplate.js +ElectronClient/gui/MainScreen/commands/setTags.js +ElectronClient/gui/MainScreen/commands/showModalMessage.js +ElectronClient/gui/MainScreen/commands/showNoteContentProperties.js +ElectronClient/gui/MainScreen/commands/showNoteProperties.js +ElectronClient/gui/MainScreen/commands/showShareNoteDialog.js +ElectronClient/gui/MainScreen/commands/toggleNoteList.js +ElectronClient/gui/MainScreen/commands/toggleSidebar.js +ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.js ElectronClient/gui/MultiNoteActions.js ElectronClient/gui/NoteContentPropertiesDialog.js +ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.js +ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.js +ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.js +ElectronClient/gui/NoteEditor/commands/showLocalSearch.js +ElectronClient/gui/NoteEditor/commands/showRevisions.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/AceEditor.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.js ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js @@ -85,12 +115,18 @@ ElectronClient/gui/NoteEditor/utils/useMessageHandler.js ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js +ElectronClient/gui/NoteList/commands/focusElementNoteList.js ElectronClient/gui/NoteListItem.js ElectronClient/gui/NoteToolbar/NoteToolbar.js ElectronClient/gui/ResourceScreen.js ElectronClient/gui/ShareNoteDialog.js +ElectronClient/gui/SideBar/commands/focusElementSideBar.js ReactNativeClient/lib/AsyncActionQueue.js ReactNativeClient/lib/checkPermissions.js +ReactNativeClient/lib/commands/historyBackward.js +ReactNativeClient/lib/commands/historyForward.js +ReactNativeClient/lib/commands/synchronize.js +ReactNativeClient/lib/hooks/useEffectDebugger.js ReactNativeClient/lib/hooks/useImperativeHandlerDebugger.js ReactNativeClient/lib/hooks/usePrevious.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/checkbox.js @@ -98,6 +134,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/services/CommandService.js ReactNativeClient/lib/services/keychain/KeychainService.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.dummy.js ReactNativeClient/lib/services/keychain/KeychainServiceDriver.mobile.js diff --git a/ElectronClient/.gitignore b/ElectronClient/.gitignore index 53f188b4d..2cfe42a9c 100644 --- a/ElectronClient/.gitignore +++ b/ElectronClient/.gitignore @@ -2,8 +2,7 @@ node_modules/ packageInfo.js dist/ lib/ -gui/*.min.js -plugins/*.min.js +*.min.js .DS_Store gui/note-viewer/pluginAssets/ pluginAssets/ \ No newline at end of file diff --git a/ElectronClient/InteropServiceHelper.js b/ElectronClient/InteropServiceHelper.js index d0887f141..a870884ae 100644 --- a/ElectronClient/InteropServiceHelper.js +++ b/ElectronClient/InteropServiceHelper.js @@ -1,6 +1,7 @@ const { _ } = require('lib/locale'); const { bridge } = require('electron').remote.require('./bridge'); const InteropService = require('lib/services/InteropService'); +const CommandService = require('lib/services/CommandService').default; const Setting = require('lib/models/Setting'); const Note = require('lib/models/Note.js'); const { friendlySafeFilename } = require('lib/path-utils'); @@ -143,11 +144,7 @@ class InteropServiceHelper { if (Array.isArray(path)) path = path[0]; - dispatch({ - type: 'WINDOW_COMMAND', - name: 'showModalMessage', - message: _('Exporting to "%s" as "%s" format. Please wait...', path, module.format), - }); + CommandService.instance().execute('showModalMessage', { message: _('Exporting to "%s" as "%s" format. Please wait...', path, module.format) }); const exportOptions = {}; exportOptions.path = path; @@ -167,10 +164,7 @@ class InteropServiceHelper { bridge().showErrorMessageBox(_('Could not export notes: %s', error.message)); } - dispatch({ - type: 'WINDOW_COMMAND', - name: 'hideModalMessage', - }); + CommandService.instance().execute('hideModalMessage'); } } diff --git a/ElectronClient/app.js b/ElectronClient/app.js index 95f09482a..76d773871 100644 --- a/ElectronClient/app.js +++ b/ElectronClient/app.js @@ -5,9 +5,7 @@ const { FoldersScreenUtils } = require('lib/folders-screen-utils.js'); const Setting = require('lib/models/Setting.js'); const { shim } = require('lib/shim.js'); const MasterKey = require('lib/models/MasterKey'); -const Note = require('lib/models/Note'); const Folder = require('lib/models/Folder'); -const { MarkupToHtml } = require('lib/joplin-renderer'); const { _, setLocale } = require('lib/locale.js'); const { Logger } = require('lib/logger.js'); const fs = require('fs-extra'); @@ -30,9 +28,53 @@ const Menu = bridge().Menu; const PluginManager = require('lib/services/PluginManager'); const RevisionService = require('lib/services/RevisionService'); const MigrationService = require('lib/services/MigrationService'); +const CommandService = require('lib/services/CommandService').default; const TemplateUtils = require('lib/TemplateUtils'); const CssUtils = require('lib/CssUtils'); +const commands = [ + require('./gui/Header/commands/focusSearch'), + require('./gui/MainScreen/commands/editAlarm'), + require('./gui/MainScreen/commands/exportPdf'), + require('./gui/MainScreen/commands/hideModalMessage'), + require('./gui/MainScreen/commands/moveToFolder'), + require('./gui/MainScreen/commands/newNote'), + require('./gui/MainScreen/commands/newNotebook'), + require('./gui/MainScreen/commands/newTodo'), + require('./gui/MainScreen/commands/print'), + require('./gui/MainScreen/commands/renameFolder'), + require('./gui/MainScreen/commands/renameTag'), + require('./gui/MainScreen/commands/search'), + require('./gui/MainScreen/commands/selectTemplate'), + require('./gui/MainScreen/commands/setTags'), + require('./gui/MainScreen/commands/showModalMessage'), + require('./gui/MainScreen/commands/showNoteContentProperties'), + require('./gui/MainScreen/commands/showNoteProperties'), + require('./gui/MainScreen/commands/showShareNoteDialog'), + require('./gui/MainScreen/commands/toggleNoteList'), + require('./gui/MainScreen/commands/toggleSidebar'), + require('./gui/MainScreen/commands/toggleVisiblePanes'), + require('./gui/NoteEditor/commands/focusElementNoteBody'), + require('./gui/NoteEditor/commands/focusElementNoteTitle'), + require('./gui/NoteEditor/commands/showLocalSearch'), + require('./gui/NoteEditor/commands/showRevisions'), + require('./gui/NoteList/commands/focusElementNoteList'), + require('./gui/SideBar/commands/focusElementSideBar'), +]; + +// Commands that are not tied to any particular component. +// The runtime for these commands can be loaded when the app starts. +const globalCommands = [ + require('./commands/focusElement'), + require('./commands/startExternalEditing'), + require('./commands/stopExternalEditing'), + require('lib/commands/synchronize'), + require('lib/commands/historyBackward'), + require('lib/commands/historyForward'), +]; + +const editorCommandDeclarations = require('./gui/NoteEditor/commands/editorCommandDeclarations').default; + const pluginClasses = [ require('./plugins/GotoAnything.min'), ]; @@ -45,7 +87,6 @@ const appDefaultState = Object.assign({}, defaultState, { }, navHistory: [], fileToImport: null, - windowCommand: null, noteVisiblePanes: ['editor', 'viewer'], sidebarVisibility: true, noteListVisibility: true, @@ -62,6 +103,14 @@ class Application extends BaseApplication { this.lastMenuScreen_ = null; this.bridge_nativeThemeUpdated = this.bridge_nativeThemeUpdated.bind(this); + + this.commandService_commandsEnabledStateChange = this.commandService_commandsEnabledStateChange.bind(this); + CommandService.instance().on('commandsEnabledStateChange', this.commandService_commandsEnabledStateChange); + } + + commandService_commandsEnabledStateChange() { + // TODO: only update if command is used in menu? + this.updateMenuItemStates(); } hasGui() { @@ -115,16 +164,6 @@ class Application extends BaseApplication { newState.windowContentSize = action.size; break; - case 'WINDOW_COMMAND': - - { - newState = Object.assign({}, state); - const command = Object.assign({}, action); - delete command.type; - newState.windowCommand = command.name ? command : null; - } - break; - case 'NOTE_VISIBLE_PANES_TOGGLE': { @@ -252,8 +291,6 @@ class Application extends BaseApplication { } async generalMiddleware(store, next, action) { - let mustUpdateMenuItemStates = false; - if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'locale' || action.type == 'SETTING_UPDATE_ALL') { setLocale(Setting.value('locale')); // The bridge runs within the main process, with its own instance of locale.js @@ -274,10 +311,6 @@ class Application extends BaseApplication { webFrame.setZoomFactor(Setting.value('windowContentZoomFactor') / 100); } - if (action.type == 'SETTING_UPDATE_ONE' && ['editor.codeView'].includes(action.key) || action.type == 'SETTING_UPDATE_ALL') { - mustUpdateMenuItemStates = true; - } - if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) { await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE'); } @@ -301,13 +334,8 @@ class Application extends BaseApplication { Setting.setValue('noteListVisibility', newState.noteListVisibility); } - if (action.type.indexOf('NOTE_SELECT') === 0 || action.type.indexOf('FOLDER_SELECT') === 0 || action.type === 'NOTE_VISIBLE_PANES_TOGGLE') { - mustUpdateMenuItemStates = true; - } - if (['NOTE_DEVTOOLS_TOGGLE', 'NOTE_DEVTOOLS_SET'].indexOf(action.type) >= 0) { this.toggleDevTools(newState.devToolsVisible); - mustUpdateMenuItemStates = true; } if (action.type === 'FOLDER_AND_NOTE_SELECT') { @@ -318,8 +346,6 @@ class Application extends BaseApplication { this.handleThemeAutoDetect(); } - if (mustUpdateMenuItemStates) this.updateMenuItemStates(newState); - return result; } @@ -339,17 +365,11 @@ class Application extends BaseApplication { await this.updateMenu(screen); } - focusElement_(target) { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: target, - }); - } - async updateMenu(screen) { if (this.lastMenuScreen_ === screen) return; + const cmdService = CommandService.instance(); + const sortNoteFolderItems = (type) => { const sortItems = []; const sortOptions = Setting.enumOptions(`${type}.sortOrder.field`); @@ -386,31 +406,12 @@ class Application extends BaseApplication { const sortNoteItems = sortNoteFolderItems('notes'); const sortFolderItems = sortNoteFolderItems('folders'); - const focusItems = []; - - focusItems.push({ - label: _('Sidebar'), - click: () => { this.focusElement_('sideBar'); }, - accelerator: 'CommandOrControl+Shift+S', - }); - - focusItems.push({ - label: _('Note list'), - click: () => { this.focusElement_('noteList'); }, - accelerator: 'CommandOrControl+Shift+L', - }); - - focusItems.push({ - label: _('Note title'), - click: () => { this.focusElement_('noteTitle'); }, - accelerator: 'CommandOrControl+Shift+N', - }); - - focusItems.push({ - label: _('Note body'), - click: () => { this.focusElement_('noteBody'); }, - accelerator: 'CommandOrControl+Shift+B', - }); + const focusItems = [ + cmdService.commandToMenuItem('focusElementSideBar', 'CommandOrControl+Shift+S'), + cmdService.commandToMenuItem('focusElementNoteList', 'CommandOrControl+Shift+L'), + cmdService.commandToMenuItem('focusElementNoteTitle', 'CommandOrControl+Shift+N'), + cmdService.commandToMenuItem('focusElementNoteBody', 'CommandOrControl+Shift+B'), + ]; let toolsItems = []; const importItems = []; @@ -456,11 +457,7 @@ class Application extends BaseApplication { if (Array.isArray(path)) path = path[0]; - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'showModalMessage', - message: _('Importing from "%s" as "%s" format. Please wait...', path, module.format), - }); + cmdService.execute('showModalMessage', { message: _('Importing from "%s" as "%s" format. Please wait...', path, module.format) }); const importOptions = { path, @@ -481,28 +478,16 @@ class Application extends BaseApplication { bridge().showErrorMessageBox(error.message); } - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'hideModalMessage', - }); + cmdService.execute('hideModalMessage'); }, }); } } } - exportItems.push({ - label: `PDF - ${_('PDF File')}`, - screens: ['Main'], - click: async () => { - const selectedNoteIds = this.store().getState().selectedNoteIds; - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'exportPdf', - noteIds: selectedNoteIds, - }); - }, - }); + exportItems.push( + cmdService.commandToMenuItem('exportPdf') + ); // We need a dummy entry, otherwise the ternary operator to show a // menu item only on a specific OS does not work. @@ -521,65 +506,10 @@ class Application extends BaseApplication { }, }; - const newNoteItem = { - label: _('New note'), - accelerator: 'CommandOrControl+N', - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'newNote', - }); - }, - }; - - const newTodoItem = { - label: _('New to-do'), - accelerator: 'CommandOrControl+T', - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'newTodo', - }); - }, - }; - - const newNotebookItem = { - label: _('New notebook'), - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'newNotebook', - }); - }, - }; - - const newSubNotebookItem = { - label: _('New sub-notebook'), - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'newSubNotebook', - activeFolderId: Setting.value('activeFolderId'), - }); - }, - }; - - const printItem = { - label: _('Print'), - accelerator: 'CommandOrControl+P', - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'print', - noteIds: this.store().getState().selectedNoteIds, - }); - }, - }; + const newNoteItem = cmdService.commandToMenuItem('newNote', 'CommandOrControl+N'); + const newTodoItem = cmdService.commandToMenuItem('newTodo', 'CommandOrControl+T'); + const newNotebookItem = cmdService.commandToMenuItem('newNotebook'); + const printItem = cmdService.commandToMenuItem('print'); toolsItemsFirst.push(syncStatusItem, { type: 'separator', @@ -592,31 +522,20 @@ class Application extends BaseApplication { label: _('Create note from template'), visible: templateDirExists, click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'selectTemplate', - noteType: 'note', - }); + cmdService.execute('selectTemplate', { noteType: 'note' }); }, }, { label: _('Create to-do from template'), visible: templateDirExists, click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'selectTemplate', - noteType: 'todo', - }); + cmdService.execute('selectTemplate', { noteType: 'todo' }); }, }, { label: _('Insert template'), visible: templateDirExists, accelerator: 'CommandOrControl+Alt+I', click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'selectTemplate', - }); + cmdService.execute('selectTemplate'); }, }, { label: _('Open template directory'), @@ -740,8 +659,7 @@ class Application extends BaseApplication { }, shim.isMac() ? noItem : newNoteItem, shim.isMac() ? noItem : newTodoItem, - shim.isMac() ? noItem : newNotebookItem, - shim.isMac() ? noItem : newSubNotebookItem, { + shim.isMac() ? noItem : newNotebookItem, { type: 'separator', visible: shim.isMac() ? false : true, }, { @@ -761,17 +679,11 @@ class Application extends BaseApplication { submenu: exportItems, }, { type: 'separator', - }, { - label: _('Synchronise'), - accelerator: 'CommandOrControl+S', - screens: ['Main'], - click: async () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'synchronize', - }); - }, - }, shim.isMac() ? syncStatusItem : noItem, { + }, + + cmdService.commandToMenuItem('synchronize', 'CommandOrControl+S'), + + shim.isMac() ? syncStatusItem : noItem, { type: 'separator', }, shim.isMac() ? noItem : printItem, { type: 'separator', @@ -796,8 +708,7 @@ class Application extends BaseApplication { submenu: [ newNoteItem, newTodoItem, - newNotebookItem, - newSubNotebookItem, { + newNotebookItem, { label: _('Close Window'), platforms: ['darwin'], accelerator: 'Command+W', @@ -833,272 +744,127 @@ class Application extends BaseApplication { }, })); + const separator = () => { + return { + type: 'separator', + screens: ['Main'], + }; + }; + const rootMenus = { edit: { id: 'edit', label: _('&Edit'), - submenu: [{ - id: 'edit:copy', - label: _('Copy'), - role: 'copy', - accelerator: 'CommandOrControl+C', - }, { - id: 'edit:cut', - label: _('Cut'), - role: 'cut', - accelerator: 'CommandOrControl+X', - }, { - id: 'edit:paste', - label: _('Paste'), - role: 'paste', - accelerator: 'CommandOrControl+V', - }, { - id: 'edit:selectAll', - label: _('Select all'), - role: 'selectall', - accelerator: 'CommandOrControl+A', - }, { - type: 'separator', - screens: ['Main'], - }, { - id: 'edit:bold', - label: _('Bold'), - screens: ['Main'], - accelerator: 'CommandOrControl+B', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textBold', - }); - }, - }, { - id: 'edit:italic', - label: _('Italic'), - screens: ['Main'], - accelerator: 'CommandOrControl+I', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textItalic', - }); - }, - }, { - id: 'edit:link', - label: _('Link'), - screens: ['Main'], - accelerator: 'CommandOrControl+K', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textLink', - }); - }, - }, { - id: 'edit:code', - label: _('Code'), - screens: ['Main'], - accelerator: 'CommandOrControl+`', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textCode', - }); - }, - }, { - type: 'separator', - screens: ['Main'], - }, { - id: 'edit:insertDateTime', - label: _('Insert Date Time'), - screens: ['Main'], - accelerator: 'CommandOrControl+Shift+T', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'insertDateTime', - }); - }, - }, { - type: 'separator', - screens: ['Main'], - }, { - id: 'edit:focusSearch', - label: _('Search in all the notes'), - screens: ['Main'], - accelerator: shim.isMac() ? 'Shift+Command+F' : 'F6', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusSearch', - }); - }, - }, { - id: 'edit:showLocalSearch', - label: _('Search in current note'), - screens: ['Main'], - accelerator: 'CommandOrControl+F', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'showLocalSearch', - }); - }, - }], + submenu: [ + cmdService.commandToMenuItem('textCopy', 'CommandOrControl+C'), + cmdService.commandToMenuItem('textCut', 'CommandOrControl+X'), + cmdService.commandToMenuItem('textPaste', 'CommandOrControl+V'), + cmdService.commandToMenuItem('textSelectAll', 'CommandOrControl+A'), + separator(), + cmdService.commandToMenuItem('textBold', 'CommandOrControl+B'), + cmdService.commandToMenuItem('textItalic', 'CommandOrControl+I'), + cmdService.commandToMenuItem('textLink', 'CommandOrControl+K'), + cmdService.commandToMenuItem('textCode', 'CommandOrControl+`'), + separator(), + cmdService.commandToMenuItem('insertDateTime', 'CommandOrControl+Shift+T'), + separator(), + cmdService.commandToMenuItem('focusSearch', shim.isMac() ? 'Shift+Command+F' : 'F6'), + cmdService.commandToMenuItem('showLocalSearch', 'CommandOrControl+F'), + ], }, view: { label: _('&View'), - submenu: [{ - label: _('Toggle sidebar'), - screens: ['Main'], - accelerator: shim.isMac() ? 'Option+Command+S' : 'F10', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'toggleSidebar', - }); + submenu: [ + CommandService.instance().commandToMenuItem('toggleSidebar', shim.isMac() ? 'Option+Command+S' : 'F10'), + CommandService.instance().commandToMenuItem('toggleNoteList'), + CommandService.instance().commandToMenuItem('toggleVisiblePanes', 'CommandOrControl+L'), + { + label: _('Layout button sequence'), + screens: ['Main'], + submenu: layoutButtonSequenceOptions, }, - }, { - type: 'separator', - screens: ['Main'], - }, { - label: _('Layout button sequence'), - screens: ['Main'], - submenu: layoutButtonSequenceOptions, - }, { - label: _('Toggle note list'), - screens: ['Main'], - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'toggleNoteList', - }); + separator(), + { + label: Setting.settingMetadata('notes.sortOrder.field').label(), + screens: ['Main'], + submenu: sortNoteItems, + }, { + label: Setting.settingMetadata('folders.sortOrder.field').label(), + screens: ['Main'], + submenu: sortFolderItems, + }, { + label: Setting.settingMetadata('showNoteCounts').label(), + type: 'checkbox', + checked: Setting.value('showNoteCounts'), + screens: ['Main'], + click: () => { + Setting.setValue('showNoteCounts', !Setting.value('showNoteCounts')); + }, + }, { + label: Setting.settingMetadata('uncompletedTodosOnTop').label(), + type: 'checkbox', + checked: Setting.value('uncompletedTodosOnTop'), + screens: ['Main'], + click: () => { + Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop')); + }, + }, { + label: Setting.settingMetadata('showCompletedTodos').label(), + type: 'checkbox', + checked: Setting.value('showCompletedTodos'), + screens: ['Main'], + click: () => { + Setting.setValue('showCompletedTodos', !Setting.value('showCompletedTodos')); + }, }, - }, { - label: _('Toggle editor layout'), - screens: ['Main'], - accelerator: 'CommandOrControl+L', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'toggleVisiblePanes', - }); + separator(), + { + label: _('Focus'), + screens: ['Main'], + submenu: focusItems, }, - }, { - type: 'separator', - screens: ['Main'], - }, { - label: Setting.settingMetadata('notes.sortOrder.field').label(), - screens: ['Main'], - submenu: sortNoteItems, - }, { - label: Setting.settingMetadata('folders.sortOrder.field').label(), - screens: ['Main'], - submenu: sortFolderItems, - }, { - label: Setting.settingMetadata('showNoteCounts').label(), - type: 'checkbox', - checked: Setting.value('showNoteCounts'), - screens: ['Main'], - click: () => { - Setting.setValue('showNoteCounts', !Setting.value('showNoteCounts')); - }, - }, { - label: Setting.settingMetadata('uncompletedTodosOnTop').label(), - type: 'checkbox', - checked: Setting.value('uncompletedTodosOnTop'), - screens: ['Main'], - click: () => { - Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop')); - }, - }, { - label: Setting.settingMetadata('showCompletedTodos').label(), - type: 'checkbox', - checked: Setting.value('showCompletedTodos'), - screens: ['Main'], - click: () => { - Setting.setValue('showCompletedTodos', !Setting.value('showCompletedTodos')); - }, - }, { - type: 'separator', - screens: ['Main'], - }, { - label: _('Focus'), - screens: ['Main'], - submenu: focusItems, - }, { - type: 'separator', - screens: ['Main'], - }, { - label: _('Actual Size'), - click: () => { - Setting.setValue('windowContentZoomFactor', 100); - }, - accelerator: 'CommandOrControl+0', - }, { + separator(), + { + label: _('Actual Size'), + click: () => { + Setting.setValue('windowContentZoomFactor', 100); + }, + accelerator: 'CommandOrControl+0', + }, { // There are 2 shortcuts for the action 'zoom in', mainly to increase the user experience. // Most applications handle this the same way. These applications indicate Ctrl +, but actually mean Ctrl =. // In fact they allow both: + and =. On the English keyboard layout - and = are used without the shift key. // So to use Ctrl + would mean to use the shift key, but this is not the case in any of the apps that show Ctrl +. // Additionally it allows the use of the plus key on the numpad. - label: _('Zoom In'), - click: () => { - Setting.incValue('windowContentZoomFactor', 10); - }, - accelerator: 'CommandOrControl+Plus', - }, { - label: _('Zoom In'), - visible: false, - click: () => { - Setting.incValue('windowContentZoomFactor', 10); - }, - accelerator: 'CommandOrControl+=', - }, { - label: _('Zoom Out'), - click: () => { - Setting.incValue('windowContentZoomFactor', -10); - }, - accelerator: 'CommandOrControl+-', - }], + label: _('Zoom In'), + click: () => { + Setting.incValue('windowContentZoomFactor', 10); + }, + accelerator: 'CommandOrControl+Plus', + }, { + label: _('Zoom In'), + visible: false, + click: () => { + Setting.incValue('windowContentZoomFactor', 10); + }, + accelerator: 'CommandOrControl+=', + }, { + label: _('Zoom Out'), + click: () => { + Setting.incValue('windowContentZoomFactor', -10); + }, + accelerator: 'CommandOrControl+-', + }], }, note: { label: _('&Note'), - submenu: [{ - id: 'edit:commandStartExternalEditing', - label: _('Edit in external editor'), - screens: ['Main'], - accelerator: 'CommandOrControl+E', - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandStartExternalEditing', - }); - }, - }, { - id: 'edit:setTags', - label: _('Tags'), - screens: ['Main'], - accelerator: 'CommandOrControl+Alt+T', - click: () => { - const selectedNoteIds = this.store().getState().selectedNoteIds; - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'setTags', - noteIds: selectedNoteIds, - }); - }, - }, { - type: 'separator', - screens: ['Main'], - }, { - id: 'note:statistics', - label: _('Statistics...'), - click: () => { - this.dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandContentProperties', - }); - }, - }], + submenu: [ + + CommandService.instance().commandToMenuItem('startExternalEditing', 'CommandOrControl+E'), + CommandService.instance().commandToMenuItem('setTags', 'CommandOrControl+Alt+T'), + separator(), + CommandService.instance().commandToMenuItem('showNoteContentProperties'), + ], }, tools: { label: _('&Tools'), @@ -1121,14 +887,11 @@ class Application extends BaseApplication { label: _('Check for updates...'), visible: shim.isMac() ? false : true, click: () => _checkForUpdates(this), - }, { - type: 'separator', - screens: ['Main'], - }, { + }, + separator(), + { id: 'help:toggleDevTools', - type: 'checkbox', label: _('Toggle development tools'), - visible: true, click: () => { this.dispatch({ type: 'NOTE_DEVTOOLS_TOGGLE', @@ -1240,49 +1003,22 @@ class Application extends BaseApplication { if (!state) state = this.store().getState(); - const selectedNoteIds = state.selectedNoteIds; - const note = selectedNoteIds.length === 1 ? await Note.load(selectedNoteIds[0]) : null; - const aceEditorViewerOnly = state.settings['editor.codeView'] && state.noteVisiblePanes.length === 1 && state.noteVisiblePanes[0] === 'viewer'; + const menuEnabledState = CommandService.instance().commandsEnabledState(this.previousMenuEnabledState); + this.previousMenuEnabledState = menuEnabledState; - // Only enabled when there's only one active note, and that note - // is a Markdown note (markup_language = MARKDOWN), and the - // editor is in edit mode (not viewer-only mode). - const singleMarkdownNoteMenuItems = [ - 'edit:bold', - 'edit:italic', - 'edit:link', - 'edit:code', - 'edit:insertDateTime', - ]; + const menu = Menu.getApplicationMenu(); - // Only enabled when there's only one active note. - const singleNoteMenuItems = [ - 'edit:copy', - 'edit:paste', - 'edit:cut', - 'edit:selectAll', - 'edit:showLocalSearch', - 'edit:commandStartExternalEditing', - 'note:statistics', - ]; - - for (const itemId of singleMarkdownNoteMenuItems) { - const menuItem = Menu.getApplicationMenu().getMenuItemById(itemId); + for (const itemId in menuEnabledState) { + const menuItem = menu.getMenuItemById(itemId); if (!menuItem) continue; - menuItem.enabled = !aceEditorViewerOnly && !!note && note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN; + menuItem.enabled = menuEnabledState[itemId]; } - for (const itemId of singleNoteMenuItems) { - const menuItem = Menu.getApplicationMenu().getMenuItemById(itemId); - if (!menuItem) continue; - menuItem.enabled = selectedNoteIds.length === 1; - } - - const sortNoteReverseItem = Menu.getApplicationMenu().getMenuItemById('sort:notes:reverse'); + const sortNoteReverseItem = menu.getMenuItemById('sort:notes:reverse'); sortNoteReverseItem.enabled = state.settings['notes.sortOrder.field'] !== 'order'; - const menuItem = Menu.getApplicationMenu().getMenuItemById('help:toggleDevTools'); - menuItem.checked = state.devToolsVisible; + // const devToolsMenuItem = menu.getMenuItemById('help:toggleDevTools'); + // devToolsMenuItem.checked = state.devToolsVisible; } bridge_nativeThemeUpdated() { @@ -1400,10 +1136,25 @@ class Application extends BaseApplication { PluginManager.instance().setLogger(reg.logger()); PluginManager.instance().register(pluginClasses); - this.updateMenu('Main'); - this.initRedux(); + CommandService.instance().initialize(this.store()); + + for (const command of commands) { + CommandService.instance().registerDeclaration(command.declaration); + } + + for (const command of globalCommands) { + CommandService.instance().registerDeclaration(command.declaration); + CommandService.instance().registerRuntime(command.declaration.name, command.runtime()); + } + + for (const declaration of editorCommandDeclarations) { + CommandService.instance().registerDeclaration(declaration); + } + + this.updateMenu('Main'); + // Since the settings need to be loaded before the store is created, it will never // receive the SETTING_UPDATE_ALL even, which mean state.settings will not be // initialised. So we manually call dispatchUpdateAll() to force an update. diff --git a/ElectronClient/commands/focusElement.ts b/ElectronClient/commands/focusElement.ts new file mode 100644 index 000000000..1e47b0826 --- /dev/null +++ b/ElectronClient/commands/focusElement.ts @@ -0,0 +1,17 @@ +import CommandService, { CommandRuntime, CommandDeclaration } from '../lib/services/CommandService'; + +export const declaration:CommandDeclaration = { + name: 'focusElement', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async ({ target }:any) => { + if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody'); + if (target === 'noteList') return CommandService.instance().execute('focusElementNoteList'); + if (target === 'sideBar') return CommandService.instance().execute('focusElementSideBar'); + if (target === 'noteTitle') return CommandService.instance().execute('focusElementNoteTitle'); + throw new Error(`Invalid focus target: ${target}`); + }, + }; +}; diff --git a/ElectronClient/commands/startExternalEditing.ts b/ElectronClient/commands/startExternalEditing.ts new file mode 100644 index 000000000..8666fdd48 --- /dev/null +++ b/ElectronClient/commands/startExternalEditing.ts @@ -0,0 +1,36 @@ +import { CommandRuntime, CommandDeclaration } from '../lib/services/CommandService'; +const { _ } = require('lib/locale'); +const Note = require('lib/models/Note'); +const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); +const { bridge } = require('electron').remote.require('./bridge'); + +interface Props { + noteId: string +} + +export const declaration:CommandDeclaration = { + name: 'startExternalEditing', + label: () => _('Edit in external editor'), + iconName: 'fa-share-square', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async (props:Props) => { + try { + const note = await Note.load(props.noteId); + ExternalEditWatcher.instance().openAndWatch(note); + } catch (error) { + bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message)); + } + + // await comp.saveNoteAndWait(comp.formNote); + }, + isEnabled: (props:any) => { + return !!props.noteId; + }, + mapStateToProps: (state:any) => { + return { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null }; + }, + }; +}; diff --git a/ElectronClient/commands/stopExternalEditing.ts b/ElectronClient/commands/stopExternalEditing.ts new file mode 100644 index 000000000..5a3f9f68a --- /dev/null +++ b/ElectronClient/commands/stopExternalEditing.ts @@ -0,0 +1,27 @@ +import { CommandRuntime, CommandDeclaration } from '../lib/services/CommandService'; +const { _ } = require('lib/locale'); +const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); + +interface Props { + noteId: string +} + +export const declaration:CommandDeclaration = { + name: 'stopExternalEditing', + label: () => _('Stop external editing'), + iconName: 'fa-stop', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async (props:Props) => { + ExternalEditWatcher.instance().stopWatching(props.noteId); + }, + isEnabled: (props:any) => { + return !!props.noteId; + }, + mapStateToProps: (state:any) => { + return { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null }; + }, + }; +}; diff --git a/ElectronClient/gui/DropboxLoginScreen.jsx b/ElectronClient/gui/DropboxLoginScreen.jsx index cf8b60e9c..c46906d88 100644 --- a/ElectronClient/gui/DropboxLoginScreen.jsx +++ b/ElectronClient/gui/DropboxLoginScreen.jsx @@ -1,7 +1,7 @@ const React = require('react'); const { connect } = require('react-redux'); const { bridge } = require('electron').remote.require('./bridge'); -const { Header } = require('./Header.min.js'); +const { Header } = require('./Header/Header.min.js'); const { themeStyle } = require('lib/theme'); const { _ } = require('lib/locale.js'); const Shared = require('lib/components/shared/dropbox-login-shared'); diff --git a/ElectronClient/gui/ErrorBoundary.tsx b/ElectronClient/gui/ErrorBoundary.tsx new file mode 100644 index 000000000..25feb636e --- /dev/null +++ b/ElectronClient/gui/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +export default class ErrorBoundary extends React.Component { + + state:any = { error: null, errorInfo: null }; + + componentDidCatch(error:any, errorInfo:any) { + this.setState({ error: error, errorInfo: errorInfo }); + } + + render() { + if (this.state.error) { + try { + const output = []; + output.push(

Message

); + output.push(

{this.state.error.message}

); + + if (this.state.error.stack) { + output.push(

Stack trace

); + output.push(
{this.state.error.stack}
); + } + + if (this.state.errorInfo) { + if (this.state.errorInfo.componentStack) { + output.push(

Component stack

); + output.push(
{this.state.errorInfo.componentStack}
); + } + } + + return ( +
+

Error

+

Joplin encountered a fatal error and could not continue. To report the error, please copy the *entire content* of this page and post it on Joplin forum or GitHub.

+ {output} +
+ ); + } catch (error) { + return ( +
+ {JSON.stringify(this.state)} +
+ ); + } + } + + return this.props.children; + } +} diff --git a/ElectronClient/gui/Header.jsx b/ElectronClient/gui/Header/Header.jsx similarity index 94% rename from ElectronClient/gui/Header.jsx rename to ElectronClient/gui/Header/Header.jsx index fdc2f78c2..3a68a100b 100644 --- a/ElectronClient/gui/Header.jsx +++ b/ElectronClient/gui/Header/Header.jsx @@ -3,6 +3,11 @@ const { connect } = require('react-redux'); const { themeStyle } = require('lib/theme'); const { _ } = require('lib/locale.js'); const { bridge } = require('electron').remote.require('./bridge'); +const CommandService = require('lib/services/CommandService').default; + +const commands = [ + require('./commands/focusSearch'), +]; class HeaderComponent extends React.Component { constructor() { @@ -13,6 +18,10 @@ class HeaderComponent extends React.Component { showButtonLabels: true, }; + for (const command of commands) { + CommandService.instance().registerRuntime(command.declaration.name, command.runtime(this)); + } + this.scheduleSearchChangeEventIid_ = null; this.searchOnQuery_ = null; this.searchElement_ = null; @@ -72,12 +81,6 @@ class HeaderComponent extends React.Component { }; } - async UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.windowCommand) { - this.doCommand(nextProps.windowCommand); - } - } - componentDidUpdate(prevProps) { if (prevProps.notesParentType !== this.props.notesParentType && this.props.notesParentType !== 'Search' && this.state.searchQuery) { this.resetSearch(); @@ -97,6 +100,10 @@ class HeaderComponent extends React.Component { clearTimeout(this.hideSearchUsageLinkIID_); this.hideSearchUsageLinkIID_ = null; } + + for (const command of commands) { + CommandService.instance().unregisterRuntime(command.declaration.name); + } } determineButtonLabelState() { @@ -110,25 +117,6 @@ class HeaderComponent extends React.Component { } } - async doCommand(command) { - if (!command) return; - - let commandProcessed = true; - - if (command.name === 'focusSearch' && this.searchElement_) { - this.searchElement_.focus(); - } else { - commandProcessed = false; - } - - if (commandProcessed) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - } - } - back_click() { this.props.dispatch({ type: 'NAV_BACK' }); } @@ -329,7 +317,6 @@ class HeaderComponent extends React.Component { const mapStateToProps = state => { return { theme: state.settings.theme, - windowCommand: state.windowCommand, notesParentType: state.notesParentType, size: state.windowContentSize, zoomFactor: state.settings.windowContentZoomFactor / 100, diff --git a/ElectronClient/gui/Header/commands/focusSearch.ts b/ElectronClient/gui/Header/commands/focusSearch.ts new file mode 100644 index 000000000..9abe4c800 --- /dev/null +++ b/ElectronClient/gui/Header/commands/focusSearch.ts @@ -0,0 +1,15 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'focusSearch', + label: () => _('Search in all the notes'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + if (comp.searchElement_) comp.searchElement_.focus(); + }, + }; +}; diff --git a/ElectronClient/gui/ImportScreen.jsx b/ElectronClient/gui/ImportScreen.jsx index 1fffe8935..e037106f8 100644 --- a/ElectronClient/gui/ImportScreen.jsx +++ b/ElectronClient/gui/ImportScreen.jsx @@ -1,7 +1,7 @@ const React = require('react'); const { connect } = require('react-redux'); const Folder = require('lib/models/Folder.js'); -const { Header } = require('./Header.min.js'); +const { Header } = require('./Header/Header.min.js'); const { themeStyle } = require('lib/theme'); const { _ } = require('lib/locale.js'); const { filename, basename } = require('lib/path-utils.js'); diff --git a/ElectronClient/gui/MainScreen.jsx b/ElectronClient/gui/MainScreen/MainScreen.jsx similarity index 50% rename from ElectronClient/gui/MainScreen.jsx rename to ElectronClient/gui/MainScreen/MainScreen.jsx index 815d1dcfe..0b9f71573 100644 --- a/ElectronClient/gui/MainScreen.jsx +++ b/ElectronClient/gui/MainScreen/MainScreen.jsx @@ -1,33 +1,50 @@ const React = require('react'); const { connect } = require('react-redux'); -const { Header } = require('./Header.min.js'); -const { SideBar } = require('./SideBar.min.js'); -const { NoteList } = require('./NoteList.min.js'); -const NoteEditor = require('./NoteEditor/NoteEditor.js').default; +const { Header } = require('../Header/Header.min.js'); +const { SideBar } = require('../SideBar/SideBar.min.js'); +const { NoteList } = require('../NoteList/NoteList.min.js'); +const NoteEditor = require('../NoteEditor/NoteEditor.js').default; const { stateUtils } = require('lib/reducer.js'); -const { PromptDialog } = require('./PromptDialog.min.js'); -const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default; -const NotePropertiesDialog = require('./NotePropertiesDialog.min.js'); -const ShareNoteDialog = require('./ShareNoteDialog.js').default; -const InteropServiceHelper = require('../InteropServiceHelper.js'); +const { PromptDialog } = require('../PromptDialog.min.js'); +const NoteContentPropertiesDialog = require('../NoteContentPropertiesDialog.js').default; +const NotePropertiesDialog = require('../NotePropertiesDialog.min.js'); +const ShareNoteDialog = require('../ShareNoteDialog.js').default; +const InteropServiceHelper = require('../../InteropServiceHelper.js'); const Setting = require('lib/models/Setting.js'); -const BaseModel = require('lib/BaseModel.js'); -const Tag = require('lib/models/Tag.js'); -const Note = require('lib/models/Note.js'); -const { uuid } = require('lib/uuid.js'); const { shim } = require('lib/shim'); -const Folder = require('lib/models/Folder.js'); const { themeStyle } = require('lib/theme.js'); const { _ } = require('lib/locale.js'); const { bridge } = require('electron').remote.require('./bridge'); -const eventManager = require('../eventManager'); -const VerticalResizer = require('./VerticalResizer.min'); +const VerticalResizer = require('../VerticalResizer.min'); const PluginManager = require('lib/services/PluginManager'); -const TemplateUtils = require('lib/TemplateUtils'); const EncryptionService = require('lib/services/EncryptionService'); +const CommandService = require('lib/services/CommandService').default; const ipcRenderer = require('electron').ipcRenderer; const { time } = require('lib/time-utils.js'); +const commands = [ + require('./commands/editAlarm'), + require('./commands/exportPdf'), + require('./commands/hideModalMessage'), + require('./commands/moveToFolder'), + require('./commands/newNote'), + require('./commands/newNotebook'), + require('./commands/newTodo'), + require('./commands/print'), + require('./commands/renameFolder'), + require('./commands/renameTag'), + require('./commands/search'), + require('./commands/selectTemplate'), + require('./commands/setTags'), + require('./commands/showModalMessage'), + require('./commands/showNoteContentProperties'), + require('./commands/showNoteProperties'), + require('./commands/showShareNoteDialog'), + require('./commands/toggleNoteList'), + require('./commands/toggleSidebar'), + require('./commands/toggleVisiblePanes'), +]; + class MainScreenComponent extends React.Component { constructor() { super(); @@ -43,15 +60,16 @@ class MainScreenComponent extends React.Component { shareNoteDialogOptions: {}, }; + this.registerCommands(); + this.setupAppCloseHandling(); + this.commandService_commandsEnabledStateChange = this.commandService_commandsEnabledStateChange.bind(this); this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this); this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this); this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); this.sidebar_onDrag = this.sidebar_onDrag.bind(this); this.noteList_onDrag = this.noteList_onDrag.bind(this); - this.commandSavePdf = this.commandSavePdf.bind(this); - this.commandPrint = this.commandPrint.bind(this); } setupAppCloseHandling() { @@ -105,17 +123,31 @@ class MainScreenComponent extends React.Component { this.setState({ shareNoteDialogOptions: {} }); } - UNSAFE_componentWillReceiveProps(newProps) { - // Execute a command if any, and if we haven't already executed it - if (newProps.windowCommand && newProps.windowCommand !== this.props.windowCommand) { - this.doCommand(newProps.windowCommand); + commandService_commandsEnabledStateChange(event) { + const buttonCommandNames = [ + 'toggleSidebar', + 'toggleNoteList', + 'newNote', + 'newTodo', + 'newNotebook', + 'toggleVisiblePanes', + ]; + + for (const n of buttonCommandNames) { + if (event.commands[n]) { + this.forceUpdate(); + return; + } } } - toggleVisiblePanes() { - this.props.dispatch({ - type: 'NOTE_VISIBLE_PANES_TOGGLE', - }); + componentDidMount() { + CommandService.instance().on('commandsEnabledStateChange', this.commandService_commandsEnabledStateChange); + } + + componentWillUnmount() { + CommandService.instance().off('commandsEnabledStateChange', this.commandService_commandsEnabledStateChange); + this.unregisterCommands(); } toggleSidebar() { @@ -130,361 +162,6 @@ class MainScreenComponent extends React.Component { }); } - async doCommand(command) { - if (!command) return; - - const createNewNote = async (template, isTodo) => { - const folderId = Setting.value('activeFolderId'); - if (!folderId) return; - - const body = template ? TemplateUtils.render(template) : ''; - - const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false }); - - let newNote = Object.assign({}, defaultValues, { - parent_id: folderId, - is_todo: isTodo ? 1 : 0, - body: body, - }); - - newNote = await Note.save(newNote, { provisional: true }); - - this.props.dispatch({ - type: 'NOTE_SELECT', - id: newNote.id, - }); - }; - - let commandProcessed = true; - - let delayedFunction = null; - let delayedArgs = null; - - if (command.name === 'newNote') { - if (!this.props.folders.length) { - bridge().showErrorMessageBox(_('Please create a notebook first.')); - } else { - await createNewNote(null, false); - } - } else if (command.name === 'newTodo') { - if (!this.props.folders.length) { - bridge().showErrorMessageBox(_('Please create a notebook first')); - } else { - await createNewNote(null, true); - } - } else if (command.name === 'newNotebook' || (command.name === 'newSubNotebook' && command.activeFolderId)) { - this.setState({ - promptOptions: { - label: _('Notebook title:'), - onClose: async answer => { - if (answer) { - let folder = null; - try { - folder = await Folder.save({ title: answer }, { userSideValidation: true }); - if (command.name === 'newSubNotebook') folder = await Folder.moveToFolder(folder.id, command.activeFolderId); - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - - if (folder) { - this.props.dispatch({ - type: 'FOLDER_SELECT', - id: folder.id, - }); - } - } - - this.setState({ promptOptions: null }); - }, - }, - }); - } else if (command.name === 'setTags') { - const tags = await Tag.commonTagsByNoteIds(command.noteIds); - const startTags = tags - .map(a => { - return { value: a.id, label: a.title }; - }) - .sort((a, b) => { - // sensitivity accent will treat accented characters as differemt - // but treats caps as equal - return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); - }); - const allTags = await Tag.allWithNotes(); - const tagSuggestions = allTags.map(a => { - return { value: a.id, label: a.title }; - }) - .sort((a, b) => { - // sensitivity accent will treat accented characters as differemt - // but treats caps as equal - return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); - }); - - this.setState({ - promptOptions: { - label: _('Add or remove tags:'), - inputType: 'tags', - value: startTags, - autocomplete: tagSuggestions, - onClose: async answer => { - if (answer !== null) { - const endTagTitles = answer.map(a => { - return a.label.trim(); - }); - if (command.noteIds.length === 1) { - await Tag.setNoteTagsByTitles(command.noteIds[0], endTagTitles); - } else { - const startTagTitles = startTags.map(a => { return a.label.trim(); }); - const addTags = endTagTitles.filter(value => !startTagTitles.includes(value)); - const delTags = startTagTitles.filter(value => !endTagTitles.includes(value)); - - // apply the tag additions and deletions to each selected note - for (let i = 0; i < command.noteIds.length; i++) { - const tags = await Tag.tagsByNoteId(command.noteIds[i]); - let tagTitles = tags.map(a => { return a.title; }); - tagTitles = tagTitles.concat(addTags); - tagTitles = tagTitles.filter(value => !delTags.includes(value)); - await Tag.setNoteTagsByTitles(command.noteIds[i], tagTitles); - } - } - } - this.setState({ promptOptions: null }); - }, - }, - }); - } else if (command.name === 'moveToFolder') { - const folders = await Folder.sortFolderTree(); - const startFolders = []; - const maxDepth = 15; - - const addOptions = (folders, depth) => { - for (let i = 0; i < folders.length; i++) { - const folder = folders[i]; - startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth }); - if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth); - } - }; - - addOptions(folders, 0); - - this.setState({ - promptOptions: { - label: _('Move to notebook:'), - inputType: 'dropdown', - value: '', - autocomplete: startFolders, - onClose: async answer => { - if (answer != null) { - for (let i = 0; i < command.noteIds.length; i++) { - await Note.moveToFolder(command.noteIds[i], answer.value); - } - } - this.setState({ promptOptions: null }); - }, - }, - }); - } else if (command.name === 'renameFolder') { - const folder = await Folder.load(command.id); - - if (folder) { - this.setState({ - promptOptions: { - label: _('Rename notebook:'), - value: folder.title, - onClose: async answer => { - if (answer !== null) { - try { - folder.title = answer; - await Folder.save(folder, { fields: ['title'], userSideValidation: true }); - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - } - this.setState({ promptOptions: null }); - }, - }, - }); - } - } else if (command.name === 'renameTag') { - const tag = await Tag.load(command.id); - if (tag) { - this.setState({ - promptOptions: { - label: _('Rename tag:'), - value: tag.title, - onClose: async answer => { - if (answer !== null) { - try { - tag.title = answer; - await Tag.save(tag, { fields: ['title'], userSideValidation: true }); - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - } - this.setState({ promptOptions: null }); - }, - }, - }); - } - } else if (command.name === 'search') { - if (!this.searchId_) this.searchId_ = uuid.create(); - - this.props.dispatch({ - type: 'SEARCH_UPDATE', - search: { - id: this.searchId_, - title: command.query, - query_pattern: command.query, - query_folder_id: null, - type_: BaseModel.TYPE_SEARCH, - }, - }); - - if (command.query) { - this.props.dispatch({ - type: 'SEARCH_SELECT', - id: this.searchId_, - }); - } else { - const note = await Note.load(this.props.selectedNoteId); - if (note) { - this.props.dispatch({ - type: 'FOLDER_AND_NOTE_SELECT', - folderId: note.parent_id, - noteId: note.id, - }); - } - } - } else if (command.name === 'commandNoteProperties') { - this.setState({ - notePropertiesDialogOptions: { - noteId: command.noteId, - visible: true, - onRevisionLinkClick: command.onRevisionLinkClick, - }, - }); - } else if (command.name === 'commandContentProperties') { - const note = await Note.load(this.props.selectedNoteId); - if (note) { - this.setState({ - noteContentPropertiesDialogOptions: { - visible: true, - text: note.body, - // lines: command.lines, - }, - }); - } - } else if (command.name === 'commandShareNoteDialog') { - this.setState({ - shareNoteDialogOptions: { - noteIds: command.noteIds, - visible: true, - }, - }); - } else if (command.name === 'toggleVisiblePanes') { - this.toggleVisiblePanes(); - } else if (command.name === 'toggleSidebar') { - this.toggleSidebar(); - } else if (command.name === 'toggleNoteList') { - this.toggleNoteList(); - } else if (command.name === 'showModalMessage') { - this.setState({ - modalLayer: { - visible: true, - message: -
-
-
{command.message}
-
, - }, - }); - } else if (command.name === 'hideModalMessage') { - this.setState({ modalLayer: { visible: false, message: '' } }); - } else if (command.name === 'editAlarm') { - const note = await Note.load(command.noteId); - - const defaultDate = new Date(Date.now() + 2 * 3600 * 1000); - defaultDate.setMinutes(0); - defaultDate.setSeconds(0); - - this.setState({ - promptOptions: { - label: _('Set alarm:'), - inputType: 'datetime', - buttons: ['ok', 'cancel', 'clear'], - value: note.todo_due ? new Date(note.todo_due) : defaultDate, - onClose: async (answer, buttonType) => { - let newNote = null; - - if (buttonType === 'clear') { - newNote = { - id: note.id, - todo_due: 0, - }; - } else if (answer !== null) { - newNote = { - id: note.id, - todo_due: answer.getTime(), - }; - } - - if (newNote) { - await Note.save(newNote); - eventManager.emit('alarmChange', { noteId: note.id, note: newNote }); - } - - this.setState({ promptOptions: null }); - }, - }, - }); - } else if (command.name === 'selectTemplate') { - this.setState({ - promptOptions: { - label: _('Template file:'), - inputType: 'dropdown', - value: this.props.templates[0], // Need to start with some value - autocomplete: this.props.templates, - onClose: async answer => { - if (answer) { - if (command.noteType === 'note' || command.noteType === 'todo') { - createNewNote(answer.value, command.noteType === 'todo'); - } else { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'insertTemplate', - value: answer.value, - }); - } - } - - this.setState({ promptOptions: null }); - }, - }, - }); - } else if (command.name === 'exportPdf') { - delayedFunction = this.commandSavePdf; - delayedArgs = { noteIds: command.noteIds }; - } else if (command.name === 'print') { - delayedFunction = this.commandPrint; - delayedArgs = { noteIds: command.noteIds }; - } else { - commandProcessed = false; - } - - if (commandProcessed) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - } - - if (delayedFunction) { - requestAnimationFrame(() => { - delayedFunction = delayedFunction.bind(this); - delayedFunction(delayedArgs); - }); - } - } - async waitForNoteToSaved(noteId) { while (noteId && this.props.editorNoteStatuses[noteId] === 'saving') { console.info('Waiting for note to be saved...', this.props.editorNoteStatuses); @@ -531,59 +208,6 @@ class MainScreenComponent extends React.Component { this.isPrinting_ = false; } - async commandSavePdf(args) { - try { - const noteIds = args.noteIds; - - if (!noteIds.length) throw new Error('No notes selected for pdf export'); - - let path = null; - if (noteIds.length === 1) { - path = bridge().showSaveDialog({ - filters: [{ name: _('PDF File'), extensions: ['pdf'] }], - defaultPath: await InteropServiceHelper.defaultFilename(noteIds[0], 'pdf'), - }); - - } else { - path = bridge().showOpenDialog({ - properties: ['openDirectory', 'createDirectory'], - }); - } - - if (!path) return; - - for (let i = 0; i < noteIds.length; i++) { - const note = await Note.load(noteIds[i]); - - let pdfPath = ''; - - if (noteIds.length === 1) { - pdfPath = path; - } else { - const n = await InteropServiceHelper.defaultFilename(note.id, 'pdf'); - pdfPath = await shim.fsDriver().findUniqueFilename(`${path}/${n}`); - } - - await this.printTo_('pdf', { path: pdfPath, noteId: note.id }); - } - } catch (error) { - console.error(error); - bridge().showErrorMessageBox(error.message); - } - } - - async commandPrint(args) { - // TODO: test - try { - const noteIds = args.noteIds; - if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.')); - - await this.printTo_('printer', { noteId: noteIds[0] }); - } catch (error) { - bridge().showErrorMessageBox(error.message); - } - } - styles(themeId, width, height, messageBoxVisible, isSidebarVisible, isNoteListVisible, sidebarWidth, noteListWidth) { const styleKey = [themeId, width, height, messageBoxVisible, +isSidebarVisible, +isNoteListVisible, sidebarWidth, noteListWidth].join('_'); if (styleKey === this.styleKey_) return this.styles_; @@ -750,6 +374,18 @@ class MainScreenComponent extends React.Component { return this.props.hasDisabledSyncItems || this.props.showMissingMasterKeyMessage || this.props.showNeedUpgradingMasterKeyMessage || this.props.showShouldReencryptMessage || this.props.hasDisabledEncryptionItems; } + registerCommands() { + for (const command of commands) { + CommandService.instance().registerRuntime(command.declaration.name, command.runtime(this)); + } + } + + unregisterCommands() { + for (const command of commands) { + CommandService.instance().unregisterRuntime(command.declaration.name); + } + } + render() { const theme = themeStyle(this.props.theme); const style = Object.assign( @@ -760,58 +396,18 @@ class MainScreenComponent extends React.Component { this.props.style, ); const promptOptions = this.state.promptOptions; - const folders = this.props.folders; const notes = this.props.notes; const sidebarVisibility = this.props.sidebarVisibility; const noteListVisibility = this.props.noteListVisibility; const styles = this.styles(this.props.theme, style.width, style.height, this.messageBoxVisible(), sidebarVisibility, noteListVisibility, this.props.sidebarWidth, this.props.noteListWidth); - const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId(); const headerItems = []; - headerItems.push({ - title: _('Toggle sidebar'), - iconName: 'fa-bars', - iconRotation: this.props.sidebarVisibility ? 0 : 90, - onClick: () => { - this.doCommand({ name: 'toggleSidebar' }); - }, - }); - - headerItems.push({ - title: _('Toggle note list'), - iconName: 'fa-align-justify', - iconRotation: noteListVisibility ? 0 : 90, - onClick: () => { - this.doCommand({ name: 'toggleNoteList' }); - }, - }); - - headerItems.push({ - title: _('New note'), - iconName: 'fa-file', - enabled: !!folders.length && !onConflictFolder, - onClick: () => { - this.doCommand({ name: 'newNote' }); - }, - }); - - headerItems.push({ - title: _('New to-do'), - iconName: 'fa-check-square', - enabled: !!folders.length && !onConflictFolder, - onClick: () => { - this.doCommand({ name: 'newTodo' }); - }, - }); - - headerItems.push({ - title: _('New notebook'), - iconName: 'fa-book', - onClick: () => { - this.doCommand({ name: 'newNotebook' }); - }, - }); + headerItems.push(CommandService.instance().commandToToolbarButton('toggleSidebar', { iconRotation: sidebarVisibility ? 0 : 90 })); + headerItems.push(CommandService.instance().commandToToolbarButton('toggleNoteList', { iconRotation: noteListVisibility ? 0 : 90 })); + headerItems.push(CommandService.instance().commandToToolbarButton('newNote')); + headerItems.push(CommandService.instance().commandToToolbarButton('newTodo')); + headerItems.push(CommandService.instance().commandToToolbarButton('newNotebook')); headerItems.push({ title: _('Code View'), @@ -829,22 +425,13 @@ class MainScreenComponent extends React.Component { }, }); - if (this.props.settingEditorCodeView) { - headerItems.push({ - title: _('Layout'), - iconName: 'fa-columns', - enabled: !!notes.length, - onClick: () => { - this.doCommand({ name: 'toggleVisiblePanes' }); - }, - }); - } + headerItems.push(CommandService.instance().commandToToolbarButton('toggleVisiblePanes')); headerItems.push({ title: _('Search...'), iconName: 'fa-search', onQuery: query => { - this.doCommand({ name: 'search', query: query }); + CommandService.instance().execute('search', { query }); }, type: 'search', }); @@ -896,7 +483,6 @@ const mapStateToProps = state => { return { theme: state.settings.theme, settingEditorCodeView: state.settings['editor.codeView'], - windowCommand: state.windowCommand, sidebarVisibility: state.sidebarVisibility, noteListVisibility: state.noteListVisibility, folders: state.folders, diff --git a/ElectronClient/gui/MainScreen/commands/editAlarm.ts b/ElectronClient/gui/MainScreen/commands/editAlarm.ts new file mode 100644 index 000000000..5a51c7b5d --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/editAlarm.ts @@ -0,0 +1,73 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Note = require('lib/models/Note'); +const BaseModel = require('lib/BaseModel'); +const { _ } = require('lib/locale'); +const eventManager = require('lib/eventManager'); +const { time } = require('lib/time-utils'); + +export const declaration:CommandDeclaration = { + name: 'editAlarm', + label: () => _('Set alarm'), + iconName: 'fa-clock', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteId }:any) => { + const note = await Note.load(noteId); + + const defaultDate = new Date(Date.now() + 2 * 3600 * 1000); + defaultDate.setMinutes(0); + defaultDate.setSeconds(0); + + comp.setState({ + promptOptions: { + label: _('Set alarm:'), + inputType: 'datetime', + buttons: ['ok', 'cancel', 'clear'], + value: note.todo_due ? new Date(note.todo_due) : defaultDate, + onClose: async (answer:any, buttonType:string) => { + let newNote = null; + + if (buttonType === 'clear') { + newNote = { + id: note.id, + todo_due: 0, + }; + } else if (answer !== null) { + newNote = { + id: note.id, + todo_due: answer.getTime(), + }; + } + + if (newNote) { + await Note.save(newNote); + eventManager.emit('alarmChange', { noteId: note.id, note: newNote }); + } + + comp.setState({ promptOptions: null }); + }, + }, + }); + }, + title: (props:any):string => { + const note = BaseModel.byId(props.notes, props.noteId); + if (!note || !note.todo_due) return null; + return time.formatMsToLocal(note.todo_due); + }, + isEnabled: (props:any):boolean => { + const { notes, noteId } = props; + if (!noteId) return false; + const note = BaseModel.byId(notes, noteId); + if (!note) return false; + return !!note.is_todo && !note.todo_completed; + }, + mapStateToProps: (state:any):any => { + return { + noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, + notes: state.notes, + }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/exportPdf.ts b/ElectronClient/gui/MainScreen/commands/exportPdf.ts new file mode 100644 index 000000000..94af19f30 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/exportPdf.ts @@ -0,0 +1,62 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Note = require('lib/models/Note'); +const { _ } = require('lib/locale'); +const { shim } = require('lib/shim'); +const { bridge } = require('electron').remote.require('./bridge'); +const InteropServiceHelper = require('../../../InteropServiceHelper.js'); + +export const declaration:CommandDeclaration = { + name: 'exportPdf', + label: () => `PDF - ${_('PDF File')}`, +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteIds }:any) => { + try { + if (!noteIds.length) throw new Error('No notes selected for pdf export'); + + let path = null; + if (noteIds.length === 1) { + path = bridge().showSaveDialog({ + filters: [{ name: _('PDF File'), extensions: ['pdf'] }], + defaultPath: await InteropServiceHelper.defaultFilename(noteIds[0], 'pdf'), + }); + + } else { + path = bridge().showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + }); + } + + if (!path) return; + + for (let i = 0; i < noteIds.length; i++) { + const note = await Note.load(noteIds[i]); + + let pdfPath = ''; + + if (noteIds.length === 1) { + pdfPath = path; + } else { + const n = await InteropServiceHelper.defaultFilename(note.id, 'pdf'); + pdfPath = await shim.fsDriver().findUniqueFilename(`${path}/${n}`); + } + + await comp.printTo_('pdf', { path: pdfPath, noteId: note.id }); + } + } catch (error) { + console.error(error); + bridge().showErrorMessageBox(error.message); + } + }, + isEnabled: (props:any):boolean => { + return !!props.noteIds.length; + }, + mapStateToProps: (state:any):any => { + return { + noteIds: state.selectedNoteIds, + }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/hideModalMessage.ts b/ElectronClient/gui/MainScreen/commands/hideModalMessage.ts new file mode 100644 index 000000000..582d97071 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/hideModalMessage.ts @@ -0,0 +1,13 @@ +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; + +export const declaration:CommandDeclaration = { + name: 'hideModalMessage', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.setState({ modalLayer: { visible: false, message: '' } }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/moveToFolder.ts b/ElectronClient/gui/MainScreen/commands/moveToFolder.ts new file mode 100644 index 000000000..55fdf4210 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/moveToFolder.ts @@ -0,0 +1,46 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Folder = require('lib/models/Folder'); +const Note = require('lib/models/Note'); +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'moveToFolder', + label: () => _('Move to notebook'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteIds }:any) => { + const folders:any[] = await Folder.sortFolderTree(); + const startFolders:any[] = []; + const maxDepth = 15; + + const addOptions = (folders:any[], depth:number) => { + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; + startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth }); + if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth); + } + }; + + addOptions(folders, 0); + + comp.setState({ + promptOptions: { + label: _('Move to notebook:'), + inputType: 'dropdown', + value: '', + autocomplete: startFolders, + onClose: async (answer:any) => { + if (answer != null) { + for (let i = 0; i < noteIds.length; i++) { + await Note.moveToFolder(noteIds[i], answer.value); + } + } + comp.setState({ promptOptions: null }); + }, + }, + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/newNote.ts b/ElectronClient/gui/MainScreen/commands/newNote.ts new file mode 100644 index 000000000..6467447ec --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/newNote.ts @@ -0,0 +1,42 @@ +import { utils, CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Setting = require('lib/models/Setting'); +const Note = require('lib/models/Note'); +const Folder = require('lib/models/Folder'); +const TemplateUtils = require('lib/TemplateUtils'); +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'newNote', + label: () => _('New note'), + iconName: 'fa-file', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async ({ template, isTodo }:any) => { + const folderId = Setting.value('activeFolderId'); + if (!folderId) return; + + const body = template ? TemplateUtils.render(template) : ''; + + const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false }); + + let newNote = Object.assign({}, defaultValues, { + parent_id: folderId, + is_todo: isTodo ? 1 : 0, + body: body, + }); + + newNote = await Note.save(newNote, { provisional: true }); + + utils.store.dispatch({ + type: 'NOTE_SELECT', + id: newNote.id, + }); + }, + isEnabled: () => { + const { folders, selectedFolderId } = utils.store.getState(); + return !!folders.length && selectedFolderId !== Folder.conflictFolderId(); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/newNotebook.ts b/ElectronClient/gui/MainScreen/commands/newNotebook.ts new file mode 100644 index 000000000..bca3296ba --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/newNotebook.ts @@ -0,0 +1,49 @@ +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); +const Folder = require('lib/models/Folder'); +const { bridge } = require('electron').remote.require('./bridge'); + +export const declaration:CommandDeclaration = { + name: 'newNotebook', + label: () => _('New notebook'), + iconName: 'fa-book', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ parentId }:any) => { + comp.setState({ + promptOptions: { + label: _('Notebook title:'), + onClose: async (answer:string) => { + if (answer) { + let folder = null; + try { + const toSave:any = { title: answer }; + if (parentId) toSave.parent_id = parentId; + folder = await Folder.save(toSave, { userSideValidation: true }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + } + + if (folder) { + comp.props.dispatch({ + type: 'FOLDER_SELECT', + id: folder.id, + }); + } + } + + comp.setState({ promptOptions: null }); + }, + }, + }); + }, + mapStateToProps: (state:any):any => { + return { + selectedNoteIds: state.selectedNoteIds, + notes: state.notes, + }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/newTodo.ts b/ElectronClient/gui/MainScreen/commands/newTodo.ts new file mode 100644 index 000000000..143ac16e2 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/newTodo.ts @@ -0,0 +1,19 @@ +import CommandService, { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'newTodo', + label: () => _('New to-do'), + iconName: 'fa-check-square', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async ({ template }:any) => { + return CommandService.instance().execute('newNote', { template: template, isTodo: true }); + }, + isEnabled: () => { + return CommandService.instance().isEnabled('newNote'); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/print.ts b/ElectronClient/gui/MainScreen/commands/print.ts new file mode 100644 index 000000000..5ab98e987 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/print.ts @@ -0,0 +1,31 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); +const { bridge } = require('electron').remote.require('./bridge'); + +export const declaration:CommandDeclaration = { + name: 'print', + label: () => _('Print'), + iconName: 'fa-file', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteIds }:any) => { + // TODO: test + try { + if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.')); + await comp.printTo_('printer', { noteId: noteIds[0] }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + } + }, + isEnabled: (props:any):boolean => { + return !!props.noteIds.length; + }, + mapStateToProps: (state:any):any => { + return { + noteIds: state.selectedNoteIds, + }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/renameFolder.ts b/ElectronClient/gui/MainScreen/commands/renameFolder.ts new file mode 100644 index 000000000..28e25d075 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/renameFolder.ts @@ -0,0 +1,37 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Folder = require('lib/models/Folder'); +const { _ } = require('lib/locale'); +const { bridge } = require('electron').remote.require('./bridge'); + +export const declaration:CommandDeclaration = { + name: 'renameFolder', + label: () => _('Rename'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ folderId }:any) => { + const folder = await Folder.load(folderId); + + if (folder) { + comp.setState({ + promptOptions: { + label: _('Rename notebook:'), + value: folder.title, + onClose: async (answer:string) => { + if (answer !== null) { + try { + folder.title = answer; + await Folder.save(folder, { fields: ['title'], userSideValidation: true }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + } + } + comp.setState({ promptOptions: null }); + }, + }, + }); + } + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/renameTag.ts b/ElectronClient/gui/MainScreen/commands/renameTag.ts new file mode 100644 index 000000000..52d55a5de --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/renameTag.ts @@ -0,0 +1,36 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Tag = require('lib/models/Tag'); +const { _ } = require('lib/locale'); +const { bridge } = require('electron').remote.require('./bridge'); + +export const declaration:CommandDeclaration = { + name: 'renameTag', + label: () => _('Rename'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ tagId }:any) => { + const tag = await Tag.load(tagId); + if (tag) { + comp.setState({ + promptOptions: { + label: _('Rename tag:'), + value: tag.title, + onClose: async (answer:string) => { + if (answer !== null) { + try { + tag.title = answer; + await Tag.save(tag, { fields: ['title'], userSideValidation: true }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + } + } + comp.setState({ promptOptions: null }); + }, + }, + }); + } + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/search.ts b/ElectronClient/gui/MainScreen/commands/search.ts new file mode 100644 index 000000000..dfe7f3947 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/search.ts @@ -0,0 +1,46 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Note = require('lib/models/Note'); +const BaseModel = require('lib/BaseModel'); +// const { _ } = require('lib/locale'); +const { uuid } = require('lib/uuid.js'); + +export const declaration:CommandDeclaration = { + name: 'search', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ query }:any) => { + console.info('RUNTIME', query); + + if (!comp.searchId_) comp.searchId_ = uuid.create(); + + comp.props.dispatch({ + type: 'SEARCH_UPDATE', + search: { + id: comp.searchId_, + title: query, + query_pattern: query, + query_folder_id: null, + type_: BaseModel.TYPE_SEARCH, + }, + }); + + if (query) { + comp.props.dispatch({ + type: 'SEARCH_SELECT', + id: comp.searchId_, + }); + } else { + const note = await Note.load(comp.props.selectedNoteId); + if (note) { + comp.props.dispatch({ + type: 'FOLDER_AND_NOTE_SELECT', + folderId: note.parent_id, + noteId: note.id, + }); + } + } + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/selectTemplate.ts b/ElectronClient/gui/MainScreen/commands/selectTemplate.ts new file mode 100644 index 000000000..7647ac80a --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/selectTemplate.ts @@ -0,0 +1,33 @@ +import CommandService, { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); +const TemplateUtils = require('lib/TemplateUtils'); + +export const declaration:CommandDeclaration = { + name: 'selectTemplate', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteType }:any) => { + comp.setState({ + promptOptions: { + label: _('Template file:'), + inputType: 'dropdown', + value: comp.props.templates[0], // Need to start with some value + autocomplete: comp.props.templates, + onClose: async (answer:any) => { + if (answer) { + if (noteType === 'note' || noteType === 'todo') { + CommandService.instance().execute('newNote', { template: answer.value, isTodo: noteType === 'todo' }); + } else { + CommandService.instance().execute('insertText', { value: TemplateUtils.render(answer.value) }); + } + } + + comp.setState({ promptOptions: null }); + }, + }, + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/setTags.ts b/ElectronClient/gui/MainScreen/commands/setTags.ts new file mode 100644 index 000000000..d2d5cbda6 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/setTags.ts @@ -0,0 +1,74 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Tag = require('lib/models/Tag'); +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'setTags', + label: () => _('Tags'), + iconName: 'fa-tags', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteIds }:any) => { + const tags = await Tag.commonTagsByNoteIds(noteIds); + const startTags = tags + .map((a:any) => { + return { value: a.id, label: a.title }; + }) + .sort((a:any, b:any) => { + // sensitivity accent will treat accented characters as differemt + // but treats caps as equal + return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); + }); + const allTags = await Tag.allWithNotes(); + const tagSuggestions = allTags.map((a:any) => { + return { value: a.id, label: a.title }; + }) + .sort((a:any, b:any) => { + // sensitivity accent will treat accented characters as differemt + // but treats caps as equal + return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); + }); + + comp.setState({ + promptOptions: { + label: _('Add or remove tags:'), + inputType: 'tags', + value: startTags, + autocomplete: tagSuggestions, + onClose: async (answer:any[]) => { + if (answer !== null) { + const endTagTitles = answer.map(a => { + return a.label.trim(); + }); + if (noteIds.length === 1) { + await Tag.setNoteTagsByTitles(noteIds[0], endTagTitles); + } else { + const startTagTitles = startTags.map((a:any) => { return a.label.trim(); }); + const addTags = endTagTitles.filter((value:string) => !startTagTitles.includes(value)); + const delTags = startTagTitles.filter((value:string) => !endTagTitles.includes(value)); + + // apply the tag additions and deletions to each selected note + for (let i = 0; i < noteIds.length; i++) { + const tags = await Tag.tagsByNoteId(noteIds[i]); + let tagTitles = tags.map((a:any) => { return a.title; }); + tagTitles = tagTitles.concat(addTags); + tagTitles = tagTitles.filter((value:string) => !delTags.includes(value)); + await Tag.setNoteTagsByTitles(noteIds[i], tagTitles); + } + } + } + comp.setState({ promptOptions: null }); + }, + }, + }); + }, + isEnabled: (props:any) => { + return !!props.noteIds.length; + }, + mapStateToProps: (state:any) => { + return { noteIds: state.selectedNoteIds }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/showModalMessage.tsx b/ElectronClient/gui/MainScreen/commands/showModalMessage.tsx new file mode 100644 index 000000000..1bcdaeeaa --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/showModalMessage.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; + +export const declaration:CommandDeclaration = { + name: 'showModalMessage', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ message }:any) => { + comp.setState({ + modalLayer: { + visible: true, + message: +
+
+
{message}
+
, + }, + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/showNoteContentProperties.ts b/ElectronClient/gui/MainScreen/commands/showNoteContentProperties.ts new file mode 100644 index 000000000..f63f2e521 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/showNoteContentProperties.ts @@ -0,0 +1,30 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const Note = require('lib/models/Note'); +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'showNoteContentProperties', + label: () => _('Statistics...'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteId }:any) => { + const note = await Note.load(noteId); + if (note) { + comp.setState({ + noteContentPropertiesDialogOptions: { + visible: true, + text: note.body, + }, + }); + } + }, + isEnabled: (props:any) => { + return !!props.noteId; + }, + mapStateToProps: (state:any) => { + return { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/showNoteProperties.ts b/ElectronClient/gui/MainScreen/commands/showNoteProperties.ts new file mode 100644 index 000000000..4ad44e42f --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/showNoteProperties.ts @@ -0,0 +1,30 @@ +import CommandService, { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'showNoteProperties', + label: () => _('Note properties'), + iconName: 'fa-info-circle', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteId }:any) => { + comp.setState({ + notePropertiesDialogOptions: { + noteId: noteId, + visible: true, + onRevisionLinkClick: () => { + CommandService.instance().execute('showRevisions'); + }, + }, + }); + }, + isEnabled: (props:any) => { + return !!props.noteId; + }, + mapStateToProps: (state:any) => { + return { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null }; + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/showShareNoteDialog.ts b/ElectronClient/gui/MainScreen/commands/showShareNoteDialog.ts new file mode 100644 index 000000000..83889e756 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/showShareNoteDialog.ts @@ -0,0 +1,20 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'showShareNoteDialog', + label: () => _('Share note...'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ noteIds }:any) => { + comp.setState({ + shareNoteDialogOptions: { + noteIds: noteIds, + visible: true, + }, + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/toggleNoteList.ts b/ElectronClient/gui/MainScreen/commands/toggleNoteList.ts new file mode 100644 index 000000000..df21c4d45 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/toggleNoteList.ts @@ -0,0 +1,18 @@ +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'toggleNoteList', + label: () => _('Toggle note list'), + iconName: 'fa-align-justify', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.props.dispatch({ + type: 'NOTELIST_VISIBILITY_TOGGLE', + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/toggleSidebar.ts b/ElectronClient/gui/MainScreen/commands/toggleSidebar.ts new file mode 100644 index 000000000..7299e4dca --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/toggleSidebar.ts @@ -0,0 +1,18 @@ +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'toggleSidebar', + label: () => _('Toggle sidebar'), + iconName: 'fa-bars', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.props.dispatch({ + type: 'SIDEBAR_VISIBILITY_TOGGLE', + }); + }, + }; +}; diff --git a/ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.ts b/ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.ts new file mode 100644 index 000000000..24f2d3446 --- /dev/null +++ b/ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.ts @@ -0,0 +1,27 @@ +import { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'toggleVisiblePanes', + label: () => _('Toggle editor layout'), + iconName: 'fa-columns', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.props.dispatch({ + type: 'NOTE_VISIBLE_PANES_TOGGLE', + }); + }, + isEnabled: (props:any):boolean => { + return props.settingEditorCodeView && props.selectedNoteIds.length === 1; + }, + mapStateToProps: (state:any):any => { + return { + selectedNoteIds: state.selectedNoteIds, + settingEditorCodeView: state.settings['editor.codeView'], + }; + }, + }; +}; diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx index d9824ce2b..79d113d1f 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; +import CommandService from '../../../../lib/services/CommandService'; const ToolbarBase = require('../../../Toolbar.min.js'); -const { _ } = require('lib/locale'); const { buildStyle, themeStyle } = require('lib/theme'); interface ToolbarProps { @@ -26,144 +26,23 @@ function styles_(props:ToolbarProps) { export default function Toolbar(props:ToolbarProps) { const styles = styles_(props); - function createToolbarItems() { - const toolbarItems = []; + const cmdService = CommandService.instance(); - toolbarItems.push({ - tooltip: _('Bold'), - iconName: 'fa-bold', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textBold', - }); - }, - }); + const toolbarItems = [ + cmdService.commandToToolbarButton('textBold'), + cmdService.commandToToolbarButton('textItalic'), + { type: 'separator' }, + cmdService.commandToToolbarButton('textLink'), + cmdService.commandToToolbarButton('textCode'), + cmdService.commandToToolbarButton('attachFile'), + { type: 'separator' }, + cmdService.commandToToolbarButton('textNumberedList'), + cmdService.commandToToolbarButton('textBulletedList'), + cmdService.commandToToolbarButton('textCheckbox'), + cmdService.commandToToolbarButton('textHeading'), + cmdService.commandToToolbarButton('textHorizontalRule'), + cmdService.commandToToolbarButton('insertDateTime'), + ]; - toolbarItems.push({ - tooltip: _('Italic'), - iconName: 'fa-italic', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textItalic', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Hyperlink'), - iconName: 'fa-link', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textLink', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Code'), - iconName: 'fa-code', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textCode', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Attach file'), - iconName: 'fa-paperclip', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'attachFile', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Numbered List'), - iconName: 'fa-list-ol', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textNumberedList', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Bulleted List'), - iconName: 'fa-list-ul', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textBulletedList', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Checkbox'), - iconName: 'fa-check-square', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textCheckbox', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Heading'), - iconName: 'fa-heading', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textHeading', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Horizontal Rule'), - iconName: 'fa-ellipsis-h', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textHorizontalRule', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Insert Date Time'), - iconName: 'fa-calendar-plus', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'insertDateTime', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - return toolbarItems; - } - - return ; + return ; } diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx index d9824ce2b..79d113d1f 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; +import CommandService from '../../../../lib/services/CommandService'; const ToolbarBase = require('../../../Toolbar.min.js'); -const { _ } = require('lib/locale'); const { buildStyle, themeStyle } = require('lib/theme'); interface ToolbarProps { @@ -26,144 +26,23 @@ function styles_(props:ToolbarProps) { export default function Toolbar(props:ToolbarProps) { const styles = styles_(props); - function createToolbarItems() { - const toolbarItems = []; + const cmdService = CommandService.instance(); - toolbarItems.push({ - tooltip: _('Bold'), - iconName: 'fa-bold', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textBold', - }); - }, - }); + const toolbarItems = [ + cmdService.commandToToolbarButton('textBold'), + cmdService.commandToToolbarButton('textItalic'), + { type: 'separator' }, + cmdService.commandToToolbarButton('textLink'), + cmdService.commandToToolbarButton('textCode'), + cmdService.commandToToolbarButton('attachFile'), + { type: 'separator' }, + cmdService.commandToToolbarButton('textNumberedList'), + cmdService.commandToToolbarButton('textBulletedList'), + cmdService.commandToToolbarButton('textCheckbox'), + cmdService.commandToToolbarButton('textHeading'), + cmdService.commandToToolbarButton('textHorizontalRule'), + cmdService.commandToToolbarButton('insertDateTime'), + ]; - toolbarItems.push({ - tooltip: _('Italic'), - iconName: 'fa-italic', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textItalic', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Hyperlink'), - iconName: 'fa-link', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textLink', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Code'), - iconName: 'fa-code', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textCode', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Attach file'), - iconName: 'fa-paperclip', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'attachFile', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - toolbarItems.push({ - tooltip: _('Numbered List'), - iconName: 'fa-list-ol', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textNumberedList', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Bulleted List'), - iconName: 'fa-list-ul', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textBulletedList', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Checkbox'), - iconName: 'fa-check-square', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textCheckbox', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Heading'), - iconName: 'fa-heading', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textHeading', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Horizontal Rule'), - iconName: 'fa-ellipsis-h', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'textHorizontalRule', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Insert Date Time'), - iconName: 'fa-calendar-plus', - onClick: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'insertDateTime', - }); - }, - }); - - toolbarItems.push({ - type: 'separator', - }); - - return toolbarItems; - } - - return ; + return ; } diff --git a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 8d46027c6..604eaa074 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -4,6 +4,7 @@ import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps } import { resourcesStatus, commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling'; import useScroll from './utils/useScroll'; import { menuItems, ContextMenuOptions, ContextMenuItemType } from '../../utils/contextMenu'; +import CommandService from '../../../../lib/services/CommandService'; const { MarkupToHtml } = require('lib/joplin-renderer'); const taboverride = require('taboverride'); const { reg } = require('lib/registry.js'); @@ -604,10 +605,7 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { tooltip: _('Insert Date Time'), icon: 'insert-time', onAction: function() { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'insertDateTime', - }); + CommandService.instance().execute('insertDateTime'); }, }); diff --git a/ElectronClient/gui/NoteEditor/NoteEditor.tsx b/ElectronClient/gui/NoteEditor/NoteEditor.tsx index 1c800eac3..23e3fe1d5 100644 --- a/ElectronClient/gui/NoteEditor/NoteEditor.tsx +++ b/ElectronClient/gui/NoteEditor/NoteEditor.tsx @@ -18,6 +18,7 @@ import useFormNote, { OnLoadEvent } from './utils/useFormNote'; import styles_ from './styles'; import { NoteEditorProps, FormNote, ScrollOptions, ScrollOptionTypes, OnChangeEvent, NoteBodyEditorProps } from './utils/types'; import ResourceEditWatcher from '../../lib/services/ResourceEditWatcher'; +import CommandService from '../../lib/services/CommandService'; const { themeStyle } = require('lib/theme'); const NoteSearchBar = require('../NoteSearchBar.min.js'); @@ -30,10 +31,14 @@ const { _ } = require('lib/locale'); const Note = require('lib/models/Note.js'); const { bridge } = require('electron').remote.require('./bridge'); const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); -const eventManager = require('../../eventManager'); +const eventManager = require('lib/eventManager'); const NoteRevisionViewer = require('../NoteRevisionViewer.min'); const TagList = require('../TagList.min.js'); +const commands = [ + require('./commands/showRevisions'), +]; + function NoteEditor(props: NoteEditorProps) { const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); @@ -222,7 +227,7 @@ function NoteEditor(props: NoteEditorProps) { } }, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]); - useWindowCommandHandler({ windowCommand: props.windowCommand, dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait }); + useWindowCommandHandler({ dispatch: props.dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait }); const onDrop = useDropHandler({ editorRef }); @@ -238,17 +243,9 @@ function NoteEditor(props: NoteEditorProps) { event.preventDefault(); if (event.shiftKey) { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteList', - }); + CommandService.instance().execute('focusElement', { target: 'noteList' }); } else { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteBody', - }); + CommandService.instance().execute('focusElement', { target: 'noteBody' }); } } }, [props.dispatch]); @@ -314,52 +311,17 @@ function NoteEditor(props: NoteEditorProps) { }; }, [externalEditWatcher_noteChange, onNotePropertyChange]); - const noteToolbar_buttonClick = useCallback((event: any) => { - const cases: any = { - - 'startExternalEditing': async () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandStartExternalEditing', - }); - }, - - 'stopExternalEditing': () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandStopExternalEditing', - }); - }, - - 'setTags': async () => { - await saveNoteAndWait(formNote); - - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'setTags', - noteIds: [formNote.id], - }); - }, - - 'setAlarm': async () => { - await saveNoteAndWait(formNote); - - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'editAlarm', - noteId: formNote.id, - }); - }, - - 'showRevisions': () => { - setShowRevisions(true); - }, + useEffect(() => { + const dependencies = { + setShowRevisions, }; - if (!cases[event.name]) throw new Error(`Unsupported event: ${event.name}`); + CommandService.instance().componentRegisterCommands(dependencies, commands); - cases[event.name](); - }, [formNote]); + return () => { + CommandService.instance().componentUnregisterCommands(commands); + }; + }, [setShowRevisions]); const onScroll = useCallback((event: any) => { props.dispatch({ @@ -389,7 +351,6 @@ function NoteEditor(props: NoteEditorProps) { theme={props.theme} note={formNote} style={toolbarStyle} - onButtonClick={noteToolbar_buttonClick} />; } @@ -474,7 +435,6 @@ function NoteEditor(props: NoteEditorProps) { padding: theme.margin, verticalAlign: 'top', boxSizing: 'border-box', - }; return ( @@ -560,7 +520,6 @@ const mapStateToProps = (state: any) => { syncStarted: state.syncStarted, theme: state.settings.theme, watchedNoteFiles: state.watchedNoteFiles, - windowCommand: state.windowCommand, notesParentType: state.notesParentType, selectedNoteTags: state.selectedNoteTags, lastEditorScrollPercents: state.lastEditorScrollPercents, diff --git a/ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.ts b/ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.ts new file mode 100644 index 000000000..5d2e3b919 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.ts @@ -0,0 +1,85 @@ +import { CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +const declarations:CommandDeclaration[] = [ + { + name: 'insertText', + }, + { + name: 'textCopy', + label: () => _('Copy'), + role: 'copy', + }, + { + name: 'textCut', + label: () => _('Cut'), + role: 'cut', + }, + { + name: 'textPaste', + label: () => _('Paste'), + role: 'paste', + }, + { + name: 'textSelectAll', + label: () => _('Select all'), + role: 'selectAll', + }, + { + name: 'textBold', + label: () => _('Bold'), + iconName: 'fa-bold', + }, + { + name: 'textItalic', + label: () => _('Italic'), + iconName: 'fa-italic', + }, + { + name: 'textLink', + label: () => _('Hyperlink'), + iconName: 'fa-link', + }, + { + name: 'textCode', + label: () => _('Code'), + iconName: 'fa-code', + }, + { + name: 'attachFile', + label: () => _('Attach file'), + iconName: 'fa-paperclip', + }, + { + name: 'textNumberedList', + label: () => _('Numbered List'), + iconName: 'fa-list-ol', + }, + { + name: 'textBulletedList', + label: () => _('Bulleted List'), + iconName: 'fa-list-ul', + }, + { + name: 'textCheckbox', + label: () => _('Checkbox'), + iconName: 'fa-check-square', + }, + { + name: 'textHeading', + label: () => _('Heading'), + iconName: 'fa-heading', + }, + { + name: 'textHorizontalRule', + label: () => _('Horizontal Rule'), + iconName: 'fa-ellipsis-h', + }, + { + name: 'insertDateTime', + label: () => _('Insert Date Time'), + iconName: 'fa-calendar-plus', + }, +]; + +export default declarations; diff --git a/ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.ts b/ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.ts new file mode 100644 index 000000000..9bec4f2a4 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.ts @@ -0,0 +1,23 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'focusElementNoteBody', + label: () => _('Note body'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.editorRef.current.execCommand({ name: 'focus' }); + }, + // isEnabled: (props:any):boolean => { + // return props.sidebarVisibility; + // }, + // mapStateToProps: (state:any):any => { + // return { + // sidebarVisibility: state.sidebarVisibility, + // }; + // }, + }; +}; diff --git a/ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.ts b/ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.ts new file mode 100644 index 000000000..3c3d3048b --- /dev/null +++ b/ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.ts @@ -0,0 +1,19 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'focusElementNoteTitle', + label: () => _('Note title'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + if (!comp.titleInputRef.current) return; + comp.titleInputRef.current.focus(); + }, + isEnabled: ():boolean => { + return !!comp.titleInputRef.current; + }, + }; +}; diff --git a/ElectronClient/gui/NoteEditor/commands/showLocalSearch.ts b/ElectronClient/gui/NoteEditor/commands/showLocalSearch.ts new file mode 100644 index 000000000..7a64e8b23 --- /dev/null +++ b/ElectronClient/gui/NoteEditor/commands/showLocalSearch.ts @@ -0,0 +1,26 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'showLocalSearch', + label: () => _('Search in current note'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + if (comp.editorRef.current && comp.editorRef.current.supportsCommand('search')) { + comp.editorRef.current.execCommand({ name: 'search' }); + } else { + comp.setShowLocalSearch(true); + if (comp.noteSearchBarRef.current) comp.noteSearchBarRef.current.wrappedInstance.focus(); + } + }, + isEnabled: (props:any) => { + return !!props.noteId; + }, + mapStateToProps: (state:any) => { + return { noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null }; + }, + }; +}; diff --git a/ElectronClient/gui/NoteEditor/commands/showRevisions.ts b/ElectronClient/gui/NoteEditor/commands/showRevisions.ts new file mode 100644 index 000000000..a12ede5ec --- /dev/null +++ b/ElectronClient/gui/NoteEditor/commands/showRevisions.ts @@ -0,0 +1,13 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; + +export const declaration:CommandDeclaration = { + name: 'showRevisions', +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async () => { + comp.setShowRevisions(true); + }, + }; +}; diff --git a/ElectronClient/gui/NoteEditor/utils/types.ts b/ElectronClient/gui/NoteEditor/utils/types.ts index 9d65bb141..956a9b1f2 100644 --- a/ElectronClient/gui/NoteEditor/utils/types.ts +++ b/ElectronClient/gui/NoteEditor/utils/types.ts @@ -13,7 +13,6 @@ export interface NoteEditorProps { editorNoteStatuses: any; syncStarted: boolean; bodyEditor: string; - windowCommand: any; folders: any[]; notesParentType: string; selectedNoteTags: any[]; diff --git a/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts index 0f1e2ee1f..e19418a6f 100644 --- a/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts +++ b/ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.ts @@ -1,12 +1,19 @@ import { useEffect } from 'react'; -import { FormNote, EditorCommand } from './types'; +import { FormNote } from './types'; +import editorCommandDeclarations from '../commands/editorCommandDeclarations'; +import CommandService, { CommandDeclaration, CommandRuntime } from '../../../lib/services/CommandService'; const { time } = require('lib/time-utils.js'); +const BaseModel = require('lib/BaseModel'); const { reg } = require('lib/registry.js'); -const NoteListUtils = require('../../utils/NoteListUtils'); -const TemplateUtils = require('lib/TemplateUtils'); +const { MarkupToHtml } = require('lib/joplin-renderer'); + +const commandsWithDependencies = [ + require('../commands/showLocalSearch'), + require('../commands/focusElementNoteTitle'), + require('../commands/focusElementNoteBody'), +]; interface HookDependencies { - windowCommand: any, formNote:FormNote, setShowLocalSearch:Function, dispatch:Function, @@ -16,96 +23,73 @@ interface HookDependencies { saveNoteAndWait: Function, } +function editorCommandRuntime(declaration:CommandDeclaration, editorRef:any):CommandRuntime { + return { + execute: (props:any) => { + console.info('Running editor command:', declaration.name, props); + if (!editorRef.current.execCommand) { + reg.logger().warn('Received command, but editor cannot execute commands', declaration.name); + } else { + const execArgs = { + name: declaration.name, + value: props.value, + }; + + if (declaration.name === 'insertDateTime') { + execArgs.name = 'insertText'; + execArgs.value = time.formatMsToLocal(new Date().getTime()); + } + + editorRef.current.execCommand(execArgs); + } + }, + isEnabled: (props:any) => { + if (props.markdownEditorViewerOnly) return false; + if (!props.noteId) return false; + const note = BaseModel.byId(props.notes, props.noteId); + if (!note) return false; + return note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN; + }, + mapStateToProps: (state:any) => { + return { + // True when the Markdown editor is active, and only the viewer pane is visible + // In this case, all editor-related shortcuts are disabled. + markdownEditorViewerOnly: state.settings['editor.codeView'] && state.noteVisiblePanes.length === 1 && state.noteVisiblePanes[0] === 'viewer', + noteVisiblePanes: state.noteVisiblePanes, + notes: state.notes, + noteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, + }; + }, + }; +} + export default function useWindowCommandHandler(dependencies:HookDependencies) { - const { windowCommand, dispatch, formNote, setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, saveNoteAndWait } = dependencies; + const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef } = dependencies; useEffect(() => { - async function processCommand() { - const command = windowCommand; - - if (!command || !formNote) return; - - reg.logger().debug('NoteEditor::useWindowCommandHandler:', command); - - const editorCmd: EditorCommand = { name: '', value: command.value }; - let fn: Function = null; - - // These commands can be forwarded directly to the note body editor - // without transformation. - const directMapCommands = [ - 'textCode', - 'textBold', - 'textItalic', - 'textLink', - 'attachFile', - 'textNumberedList', - 'textBulletedList', - 'textCheckbox', - 'textHeading', - 'textHorizontalRule', - ]; - - if (directMapCommands.includes(command.name)) { - editorCmd.name = command.name; - } else if (command.name === 'commandStartExternalEditing') { - fn = async () => { - await saveNoteAndWait(formNote); - NoteListUtils.startExternalEditing(formNote.id); - }; - } else if (command.name === 'commandStopExternalEditing') { - fn = () => { - NoteListUtils.stopExternalEditing(formNote.id); - }; - } else if (command.name === 'insertDateTime') { - editorCmd.name = 'insertText', - editorCmd.value = time.formatMsToLocal(new Date().getTime()); - } else if (command.name === 'showLocalSearch') { - if (editorRef.current && editorRef.current.supportsCommand('search')) { - editorCmd.name = 'search'; - } else { - fn = () => { - setShowLocalSearch(true); - if (noteSearchBarRef.current) noteSearchBarRef.current.wrappedInstance.focus(); - }; - } - } else if (command.name === 'insertTemplate') { - editorCmd.name = 'insertText'; - editorCmd.value = TemplateUtils.render(command.value); - } - - if (command.name === 'focusElement' && command.target === 'noteTitle') { - fn = () => { - if (!titleInputRef.current) return; - titleInputRef.current.focus(); - }; - } - - if (command.name === 'focusElement' && command.target === 'noteBody') { - editorCmd.name = 'focus'; - } - - reg.logger().debug('NoteEditor::useWindowCommandHandler: Dispatch:', editorCmd, fn); - - if (!editorCmd.name && !fn) return; - - dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - - requestAnimationFrame(() => { - if (fn) { - fn(); - } else { - if (!editorRef.current.execCommand) { - reg.logger().warn('Received command, but editor cannot execute commands', editorCmd); - } else { - editorRef.current.execCommand(editorCmd); - } - } - }); + for (const declaration of editorCommandDeclarations) { + CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef)); } - processCommand(); - }, [windowCommand, dispatch, formNote, saveNoteAndWait]); + const dependencies = { + editorRef, + setShowLocalSearch, + noteSearchBarRef, + titleInputRef, + }; + + for (const command of commandsWithDependencies) { + CommandService.instance().registerRuntime(command.declaration.name, command.runtime(dependencies)); + } + + return () => { + for (const declaration of editorCommandDeclarations) { + CommandService.instance().unregisterRuntime(declaration.name); + } + + for (const command of commandsWithDependencies) { + CommandService.instance().unregisterRuntime(command.declaration.name); + } + }; + }, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef]); } diff --git a/ElectronClient/gui/NoteList.jsx b/ElectronClient/gui/NoteList/NoteList.jsx similarity index 92% rename from ElectronClient/gui/NoteList.jsx rename to ElectronClient/gui/NoteList/NoteList.jsx index df3aaa638..d1ee63779 100644 --- a/ElectronClient/gui/NoteList.jsx +++ b/ElectronClient/gui/NoteList/NoteList.jsx @@ -1,4 +1,4 @@ -const { ItemList } = require('./ItemList.min.js'); +const { ItemList } = require('../ItemList.min.js'); const React = require('react'); const { connect } = require('react-redux'); const { time } = require('lib/time-utils.js'); @@ -6,17 +6,24 @@ const { themeStyle } = require('lib/theme'); const BaseModel = require('lib/BaseModel'); const { _ } = require('lib/locale.js'); const { bridge } = require('electron').remote.require('./bridge'); -const eventManager = require('../eventManager'); +const eventManager = require('lib/eventManager'); const SearchEngine = require('lib/services/SearchEngine'); const Note = require('lib/models/Note'); const Setting = require('lib/models/Setting'); -const NoteListUtils = require('./utils/NoteListUtils'); -const NoteListItem = require('./NoteListItem').default; +const NoteListUtils = require('../utils/NoteListUtils'); +const NoteListItem = require('../NoteListItem').default; +const CommandService = require('lib/services/CommandService.js').default; + +const commands = [ + require('./commands/focusElementNoteList'), +]; class NoteListComponent extends React.Component { constructor() { super(); + CommandService.instance().componentRegisterCommands(this, commands); + this.itemHeight = 34; this.state = { @@ -260,33 +267,7 @@ class NoteListComponent extends React.Component { return null; } - doCommand(command) { - if (!command) return; - - let commandProcessed = true; - - if (command.name === 'focusElement' && command.target === 'noteList') { - if (this.props.selectedNoteIds.length) { - const ref = this.itemAnchorRef(this.props.selectedNoteIds[0]); - if (ref) ref.focus(); - } - } else { - commandProcessed = false; - } - - if (commandProcessed) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - } - } - componentDidUpdate(prevProps) { - if (prevProps.windowCommand !== this.props.windowCommand) { - this.doCommand(this.props.windowCommand); - } - if (prevProps.selectedNoteIds !== this.props.selectedNoteIds && this.props.selectedNoteIds.length === 1) { const id = this.props.selectedNoteIds[0]; const doRefocus = this.props.notes.length < prevProps.notes.length; @@ -387,17 +368,9 @@ class NoteListComponent extends React.Component { event.preventDefault(); if (event.shiftKey) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'sideBar', - }); + CommandService.instance().execute('focusElement', { target: 'sideBar' }); } else { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteTitle', - }); + CommandService.instance().execute('focusElement', { target: 'noteTitle' }); } } @@ -435,6 +408,8 @@ class NoteListComponent extends React.Component { clearInterval(this.focusItemIID_); this.focusItemIID_ = null; } + + CommandService.instance().componentUnregisterCommands(commands); } render() { @@ -482,7 +457,6 @@ const mapStateToProps = state => { searches: state.searches, selectedSearchId: state.selectedSearchId, watchedNoteFiles: state.watchedNoteFiles, - windowCommand: state.windowCommand, provisionalNoteIds: state.provisionalNoteIds, isInsertingNotes: state.isInsertingNotes, noteSortOrder: state.settings['notes.sortOrder.field'], diff --git a/ElectronClient/gui/NoteList/commands/focusElementNoteList.ts b/ElectronClient/gui/NoteList/commands/focusElementNoteList.ts new file mode 100644 index 000000000..1e8dfa1cf --- /dev/null +++ b/ElectronClient/gui/NoteList/commands/focusElementNoteList.ts @@ -0,0 +1,26 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'focusElementNoteList', + label: () => _('Note list'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ selectedNoteIds }:any) => { + if (selectedNoteIds.length) { + const ref = comp.itemAnchorRef(selectedNoteIds[0]); + if (ref) ref.focus(); + } + }, + isEnabled: (props:any):boolean => { + return !!props.selectedNoteIds.length; + }, + mapStateToProps: (state:any):any => { + return { + selectedNoteIds: state.selectedNoteIds, + }; + }, + }; +}; diff --git a/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx index 5ea306032..f0084cb0f 100644 --- a/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx +++ b/ElectronClient/gui/NoteToolbar/NoteToolbar.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; +import { useEffect, useCallback, useState } from 'react'; +import CommandService from '../../lib/services/CommandService'; const { connect } = require('react-redux'); const { buildStyle } = require('lib/theme'); const Toolbar = require('../Toolbar.min.js'); -const Note = require('lib/models/Note'); const Folder = require('lib/models/Folder'); -const { time } = require('lib/time-utils.js'); const { _ } = require('lib/locale'); const { substrWithEllipsis } = require('lib/string-utils'); @@ -36,116 +36,61 @@ function styles_(props:NoteToolbarProps) { }); } -function useToolbarItems(props:NoteToolbarProps) { - const { note, folders, watchedNoteFiles, notesParentType, dispatch - , onButtonClick, backwardHistoryNotes, forwardHistoryNotes } = props; - - const toolbarItems = []; - - const selectedNoteFolder = Folder.byId(folders, note.parent_id); - - toolbarItems.push({ - tooltip: _('Back'), - iconName: 'fa-arrow-left', - enabled: (backwardHistoryNotes.length > 0), - onClick: () => { - if (!backwardHistoryNotes.length) return; - props.dispatch({ - type: 'HISTORY_BACKWARD', - }); - }, - }); - - toolbarItems.push({ - tooltip: _('Forward'), - iconName: 'fa-arrow-right', - enabled: (forwardHistoryNotes.length > 0), - onClick: () => { - if (!forwardHistoryNotes.length) return; - props.dispatch({ - type: 'HISTORY_FORWARD', - }); - }, - }); - - if (selectedNoteFolder && ['Search', 'Tag', 'SmartFilter'].includes(notesParentType)) { - toolbarItems.push({ - title: _('In: %s', substrWithEllipsis(selectedNoteFolder.title, 0, 16)), - iconName: 'fa-book', - onClick: () => { - props.dispatch({ - type: 'FOLDER_AND_NOTE_SELECT', - folderId: selectedNoteFolder.id, - noteId: note.id, - }); - }, - }); - } - - toolbarItems.push({ - tooltip: _('Note properties'), - iconName: 'fa-info-circle', - onClick: () => { - dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandNoteProperties', - noteId: note.id, - onRevisionLinkClick: () => { - onButtonClick({ name: 'showRevisions' }); - }, - }); - }, - }); - - if (watchedNoteFiles.indexOf(note.id) >= 0) { - toolbarItems.push({ - tooltip: _('Click to stop external editing'), - title: _('Watching...'), - iconName: 'fa-share-square', - onClick: () => { - onButtonClick({ name: 'stopExternalEditing' }); - }, - }); - } else { - toolbarItems.push({ - tooltip: _('Edit in external editor'), - iconName: 'fa-share-square', - onClick: () => { - onButtonClick({ name: 'startExternalEditing' }); - }, - }); - } - - if (note.is_todo) { - const item:any = { - iconName: 'fa-clock', - enabled: !note.todo_completed, - onClick: () => { - onButtonClick({ name: 'setAlarm' }); - }, - }; - if (Note.needAlarm(note)) { - item.title = time.formatMsToLocal(note.todo_due); - } else { - item.tooltip = _('Set alarm'); - } - toolbarItems.push(item); - } - - toolbarItems.push({ - tooltip: _('Tags'), - iconName: 'fa-tags', - onClick: () => { - onButtonClick({ name: 'setTags' }); - }, - }); - - return toolbarItems; -} - function NoteToolbar(props:NoteToolbarProps) { const styles = styles_(props); - const toolbarItems = useToolbarItems(props); + const [toolbarItems, setToolbarItems] = useState([]); + const selectedNoteFolder = Folder.byId(props.folders, props.note.parent_id); + + const cmdService = CommandService.instance(); + + const updateToolbarItems = useCallback(() => { + const output = []; + + output.push( + cmdService.commandToToolbarButton('historyBackward') + ); + + output.push( + cmdService.commandToToolbarButton('historyForward') + ); + + if (selectedNoteFolder.id && ['Search', 'Tag', 'SmartFilter'].includes(props.notesParentType)) { + output.push({ + title: _('In: %s', substrWithEllipsis(selectedNoteFolder.title, 0, 16)), + iconName: 'fa-book', + onClick: () => { + props.dispatch({ + type: 'FOLDER_AND_NOTE_SELECT', + folderId: selectedNoteFolder.id, + noteId: props.note.id, + }); + }, + }); + } + + output.push(cmdService.commandToToolbarButton('showNoteProperties')); + + if (props.watchedNoteFiles.indexOf(props.note.id) >= 0) { + output.push(cmdService.commandToToolbarButton('stopExternalEditing')); + } else { + output.push(cmdService.commandToToolbarButton('startExternalEditing')); + } + + output.push(cmdService.commandToToolbarButton('editAlarm')); + + output.push(cmdService.commandToToolbarButton('setTags')); + + setToolbarItems(output); + }, [props.note.id, selectedNoteFolder.id, selectedNoteFolder.title, props.watchedNoteFiles, props.notesParentType]); + + useEffect(() => { + updateToolbarItems(); + cmdService.on('commandsEnabledStateChange', updateToolbarItems); + return () => { + cmdService.off('commandsEnabledStateChange', updateToolbarItems); + }; + }, [updateToolbarItems]); + return ; } diff --git a/ElectronClient/gui/OneDriveLoginScreen.jsx b/ElectronClient/gui/OneDriveLoginScreen.jsx index 2deefe475..ef9f058fa 100644 --- a/ElectronClient/gui/OneDriveLoginScreen.jsx +++ b/ElectronClient/gui/OneDriveLoginScreen.jsx @@ -3,7 +3,7 @@ const { connect } = require('react-redux'); const { reg } = require('lib/registry.js'); const Setting = require('lib/models/Setting'); const { bridge } = require('electron').remote.require('./bridge'); -const { Header } = require('./Header.min.js'); +const { Header } = require('./Header/Header.min.js'); const { themeStyle } = require('lib/theme'); const { _ } = require('lib/locale.js'); const { OneDriveApiNodeUtils } = require('lib/onedrive-api-node-utils.js'); diff --git a/ElectronClient/gui/ResourceScreen.tsx b/ElectronClient/gui/ResourceScreen.tsx index cbd62b2e6..5244e04d2 100644 --- a/ElectronClient/gui/ResourceScreen.tsx +++ b/ElectronClient/gui/ResourceScreen.tsx @@ -4,7 +4,7 @@ const { connect } = require('react-redux'); const { _ } = require('lib/locale.js'); const { themeStyle } = require('lib/theme'); const { bridge } = require('electron').remote.require('./bridge'); -const { Header } = require('./Header.min.js'); +const { Header } = require('./Header/Header.min.js'); const prettyBytes = require('pretty-bytes'); const Resource = require('lib/models/Resource.js'); diff --git a/ElectronClient/gui/Root.jsx b/ElectronClient/gui/Root.jsx index 54e8fc36f..030a17540 100644 --- a/ElectronClient/gui/Root.jsx +++ b/ElectronClient/gui/Root.jsx @@ -5,7 +5,8 @@ const { connect, Provider } = require('react-redux'); const { _ } = require('lib/locale.js'); const Setting = require('lib/models/Setting.js'); -const { MainScreen } = require('./MainScreen.min.js'); +const { MainScreen } = require('.//MainScreen/MainScreen.min.js'); +const ErrorBoundary = require('./ErrorBoundary').default; const { OneDriveLoginScreen } = require('./OneDriveLoginScreen.min.js'); const { DropboxLoginScreen } = require('./DropboxLoginScreen.min.js'); const { StatusScreen } = require('./StatusScreen.min.js'); @@ -14,7 +15,6 @@ const { ConfigScreen } = require('./ConfigScreen.min.js'); const { ResourceScreen } = require('./ResourceScreen.js'); const { Navigator } = require('./Navigator.min.js'); const WelcomeUtils = require('lib/WelcomeUtils'); - const { app } = require('../app'); const { bridge } = require('electron').remote.require('./bridge'); @@ -112,7 +112,9 @@ const store = app().store(); render( - + + + , document.getElementById('react-root') ); diff --git a/ElectronClient/gui/SideBar.jsx b/ElectronClient/gui/SideBar/SideBar.jsx similarity index 87% rename from ElectronClient/gui/SideBar.jsx rename to ElectronClient/gui/SideBar/SideBar.jsx index 643f166e1..4c4b0926f 100644 --- a/ElectronClient/gui/SideBar.jsx +++ b/ElectronClient/gui/SideBar/SideBar.jsx @@ -2,6 +2,7 @@ const React = require('react'); const { connect } = require('react-redux'); const shared = require('lib/components/shared/side-menu-shared.js'); const { Synchronizer } = require('lib/synchronizer.js'); +const CommandService = require('lib/services/CommandService.js').default; const BaseModel = require('lib/BaseModel.js'); const Setting = require('lib/models/Setting.js'); const Folder = require('lib/models/Folder.js'); @@ -12,14 +13,20 @@ const { themeStyle } = require('lib/theme'); const { bridge } = require('electron').remote.require('./bridge'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; -const InteropServiceHelper = require('../InteropServiceHelper.js'); +const InteropServiceHelper = require('../../InteropServiceHelper.js'); const { substrWithEllipsis } = require('lib/string-utils'); const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids'); +const commands = [ + require('./commands/focusElementSideBar'), +]; + class SideBarComponent extends React.Component { constructor() { super(); + CommandService.instance().componentRegisterCommands(this, commands); + this.onFolderDragStart_ = event => { const folderId = event.currentTarget.getAttribute('folderid'); if (!folderId) return; @@ -213,61 +220,10 @@ class SideBarComponent extends React.Component { } } - doCommand(command) { - if (!command) return; - - let commandProcessed = true; - - if (command.name === 'focusElement' && command.target === 'sideBar') { - if (this.props.sidebarVisibility) { - const item = this.selectedItem(); - if (item) { - const anchorRef = this.anchorItemRefs[item.type][item.id]; - if (anchorRef) anchorRef.current.focus(); - } else { - const anchorRef = this.firstAnchorItemRef('folder'); - console.info('anchorRef', anchorRef); - if (anchorRef) anchorRef.current.focus(); - } - } - } else if (command.name === 'synchronize') { - if (!this.props.syncStarted) this.sync_click(); - } else { - commandProcessed = false; - } - - if (commandProcessed) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: null, - }); - } - } - componentWillUnmount() { this.clearForceUpdateDuringSync(); - } - componentDidUpdate(prevProps) { - if (prevProps.windowCommand !== this.props.windowCommand) { - this.doCommand(this.props.windowCommand); - } - - // if (shim.isLinux()) { - // // For some reason, the UI seems to sleep in some Linux distro during - // // sync. Cannot find the reason for it and cannot replicate, so here - // // as a test force the update at regular intervals. - // // https://github.com/laurent22/joplin/issues/312#issuecomment-429472193 - // if (!prevProps.syncStarted && this.props.syncStarted) { - // this.clearForceUpdateDuringSync(); - - // this.forceUpdateDuringSyncIID_ = setInterval(() => { - // this.forceUpdate(); - // }, 2000); - // } - - // if (prevProps.syncStarted && !this.props.syncStarted) this.clearForceUpdateDuringSync(); - // } + CommandService.instance().componentUnregisterCommands(commands); } async itemContextMenu(event) { @@ -299,16 +255,7 @@ class SideBarComponent extends React.Component { if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { menu.append( - new MenuItem({ - label: _('New sub-notebook'), - click: () => { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'newSubNotebook', - activeFolderId: itemId, - }); - }, - }) + new MenuItem(CommandService.instance().commandToMenuItem('newNotebook', null, itemId)), ); } @@ -337,31 +284,7 @@ class SideBarComponent extends React.Component { ); if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) { - menu.append( - new MenuItem({ - label: _('Rename'), - click: async () => { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'renameFolder', - id: itemId, - }); - }, - }) - ); - - // menu.append( - // new MenuItem({ - // label: _("Move"), - // click: async () => { - // this.props.dispatch({ - // type: "WINDOW_COMMAND", - // name: "renameFolder", - // id: itemId, - // }); - // }, - // }) - // ); + menu.append(new MenuItem(CommandService.instance().commandToMenuItem('renameFolder', null, { folderId: itemId }))); menu.append(new MenuItem({ type: 'separator' })); @@ -393,18 +316,9 @@ class SideBarComponent extends React.Component { } if (itemType === BaseModel.TYPE_TAG) { - menu.append( - new MenuItem({ - label: _('Rename'), - click: async () => { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'renameTag', - id: itemId, - }); - }, - }) - ); + menu.append(new MenuItem( + CommandService.instance().commandToMenuItem('renameTag', null, { tagId: itemId }) + )); } menu.popup(bridge().window()); @@ -424,9 +338,9 @@ class SideBarComponent extends React.Component { }); } - async sync_click() { - await shared.synchronize_press(this); - } + // async sync_click() { + // await shared.synchronize_press(this); + // } anchorItemRef(type, id) { if (!this.anchorItemRefs[type]) this.anchorItemRefs[type] = {}; @@ -662,17 +576,9 @@ class SideBarComponent extends React.Component { event.preventDefault(); if (event.shiftKey) { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteBody', - }); + CommandService.instance().execute('focusElement', { target: 'noteBody' }); } else { - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteList', - }); + CommandService.instance().execute('focusElement', { target: 'noteList' }); } } @@ -728,7 +634,8 @@ class SideBarComponent extends React.Component { href="#" key="sync_button" onClick={() => { - this.sync_click(); + CommandService.instance().execute('synchronize'); + // this.sync_click(); }} > {icon} @@ -849,7 +756,6 @@ const mapStateToProps = state => { collapsedFolderIds: state.collapsedFolderIds, decryptionWorker: state.decryptionWorker, resourceFetcher: state.resourceFetcher, - windowCommand: state.windowCommand, sidebarVisibility: state.sidebarVisibility, noteListVisibility: state.noteListVisibility, }; diff --git a/ElectronClient/gui/SideBar/commands/focusElementSideBar.ts b/ElectronClient/gui/SideBar/commands/focusElementSideBar.ts new file mode 100644 index 000000000..90238d8d1 --- /dev/null +++ b/ElectronClient/gui/SideBar/commands/focusElementSideBar.ts @@ -0,0 +1,32 @@ +import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'focusElementSideBar', + label: () => _('Sidebar'), +}; + +export const runtime = (comp:any):CommandRuntime => { + return { + execute: async ({ sidebarVisibility }:any) => { + if (sidebarVisibility) { + const item = comp.selectedItem(); + if (item) { + const anchorRef = comp.anchorItemRefs[item.type][item.id]; + if (anchorRef) anchorRef.current.focus(); + } else { + const anchorRef = comp.firstAnchorItemRef('folder'); + if (anchorRef) anchorRef.current.focus(); + } + } + }, + isEnabled: (props:any):boolean => { + return props.sidebarVisibility; + }, + mapStateToProps: (state:any):any => { + return { + sidebarVisibility: state.sidebarVisibility, + }; + }, + }; +}; diff --git a/ElectronClient/gui/StatusScreen.jsx b/ElectronClient/gui/StatusScreen.jsx index bcf802a53..8f568b3c8 100644 --- a/ElectronClient/gui/StatusScreen.jsx +++ b/ElectronClient/gui/StatusScreen.jsx @@ -2,7 +2,7 @@ const React = require('react'); const { connect } = require('react-redux'); const Setting = require('lib/models/Setting.js'); const { bridge } = require('electron').remote.require('./bridge'); -const { Header } = require('./Header.min.js'); +const { Header } = require('./Header/Header.min.js'); const { themeStyle } = require('lib/theme'); const { _ } = require('lib/locale.js'); const { ReportService } = require('lib/services/report.js'); diff --git a/ElectronClient/gui/utils/NoteListUtils.js b/ElectronClient/gui/utils/NoteListUtils.js index 58e698159..4d11c114b 100644 --- a/ElectronClient/gui/utils/NoteListUtils.js +++ b/ElectronClient/gui/utils/NoteListUtils.js @@ -3,15 +3,17 @@ const { _ } = require('lib/locale.js'); const { bridge } = require('electron').remote.require('./bridge'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; -const eventManager = require('../../eventManager'); +const eventManager = require('lib/eventManager'); const InteropService = require('lib/services/InteropService'); const InteropServiceHelper = require('../../InteropServiceHelper.js'); const Note = require('lib/models/Note'); -const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); +const CommandService = require('lib/services/CommandService').default; const { substrWithEllipsis } = require('lib/string-utils'); class NoteListUtils { static makeContextMenu(noteIds, props) { + const cmdService = CommandService.instance(); + const notes = noteIds.map(id => BaseModel.byId(props.notes, id)); let hasEncrypted = false; @@ -23,29 +25,11 @@ class NoteListUtils { if (!hasEncrypted) { menu.append( - new MenuItem({ - label: _('Add or remove tags'), - click: async () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'setTags', - noteIds: noteIds, - }); - }, - }) + new MenuItem(cmdService.commandToMenuItem('setTags')) ); menu.append( - new MenuItem({ - label: _('Move to notebook'), - click: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'moveToFolder', - noteIds: noteIds, - }); - }, - }) + new MenuItem(cmdService.commandToMenuItem('moveToFolder')) ); menu.append( @@ -64,23 +48,11 @@ class NoteListUtils { if (props.watchedNoteFiles.indexOf(noteIds[0]) < 0) { menu.append( - new MenuItem({ - label: _('Edit in external editor'), - enabled: noteIds.length === 1, - click: async () => { - this.startExternalEditing(noteIds[0]); - }, - }) + new MenuItem(cmdService.commandToMenuItem('startExternalEditing', null, { noteId: noteIds[0] })) ); } else { menu.append( - new MenuItem({ - label: _('Stop external editing'), - enabled: noteIds.length === 1, - click: async () => { - this.stopExternalEditing(noteIds[0]); - }, - }) + new MenuItem(cmdService.commandToMenuItem('stopExternalEditing', null, { noteId: noteIds[0] })) ); } @@ -149,17 +121,9 @@ class NoteListUtils { ); menu.append( - new MenuItem({ - label: _('Share note...'), - click: async () => { - console.info('NOTE IDS', noteIds); - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'commandShareNoteDialog', - noteIds: noteIds.slice(), - }); - }, - }) + new MenuItem( + cmdService.commandToMenuItem('showShareNoteDialog', null, { noteIds: noteIds.slice() }) + ) ); const exportMenu = new Menu(); @@ -182,16 +146,9 @@ class NoteListUtils { } exportMenu.append( - new MenuItem({ - label: `PDF - ${_('PDF File')}`, - click: () => { - props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'exportPdf', - noteIds: noteIds, - }); - }, - }) + new MenuItem( + cmdService.commandToMenuItem('exportPdf', null, { noteIds: noteIds }) + ) ); const exportMenuItem = new MenuItem({ label: _('Export'), submenu: exportMenu }); @@ -232,19 +189,6 @@ class NoteListUtils { await Note.batchDelete(noteIds); } - static async startExternalEditing(noteId) { - try { - const note = await Note.load(noteId); - ExternalEditWatcher.instance().openAndWatch(note); - } catch (error) { - bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message)); - } - } - - static async stopExternalEditing(noteId) { - ExternalEditWatcher.instance().stopWatching(noteId); - } - } module.exports = NoteListUtils; diff --git a/ElectronClient/main-html.js b/ElectronClient/main-html.js index 9cf32a1d3..5fe0dab33 100644 --- a/ElectronClient/main-html.js +++ b/ElectronClient/main-html.js @@ -98,6 +98,8 @@ document.addEventListener('click', (event) => event.preventDefault()); app().start(bridge().processArgv()).then(() => { require('./gui/Root.min.js'); }).catch((error) => { + const env = bridge().env(); + if (error.code == 'flagError') { bridge().showErrorMessageBox(error.message); } else { @@ -107,8 +109,14 @@ app().start(bridge().processArgv()).then(() => { if (error.fileName) msg.push(error.fileName); if (error.lineNumber) msg.push(error.lineNumber); if (error.stack) msg.push(error.stack); - bridge().showErrorMessageBox(msg.join('\n\n')); + + if (env === 'dev') { + console.error(error); + } else { + bridge().showErrorMessageBox(msg.join('\n\n')); + } } - bridge().electronApp().exit(1); + // In dev, we leave the app open as debug statements in the console can be useful + if (env !== 'dev') bridge().electronApp().exit(1); }); diff --git a/ElectronClient/plugins/GotoAnything.jsx b/ElectronClient/plugins/GotoAnything.jsx index 55067c039..731802cbd 100644 --- a/ElectronClient/plugins/GotoAnything.jsx +++ b/ElectronClient/plugins/GotoAnything.jsx @@ -3,6 +3,7 @@ const { connect } = require('react-redux'); const { _ } = require('lib/locale.js'); const { themeStyle } = require('lib/theme'); const SearchEngine = require('lib/services/SearchEngine'); +const CommandService = require('lib/services/CommandService').default; const BaseModel = require('lib/BaseModel'); const Tag = require('lib/models/Tag'); const Folder = require('lib/models/Folder'); @@ -301,11 +302,7 @@ class Dialog extends React.PureComponent { noteId: item.id, }); - this.props.dispatch({ - type: 'WINDOW_COMMAND', - name: 'focusElement', - target: 'noteBody', - }); + CommandService.instance().scheduleExecute('focusElement', { target: 'noteBody' }); } else if (this.state.listType === BaseModel.TYPE_TAG) { this.props.dispatch({ type: 'TAG_SELECT', diff --git a/ElectronClient/tools/compileScripts.js b/ElectronClient/tools/compileScripts.js index 12c03aa02..eb73fc41b 100644 --- a/ElectronClient/tools/compileScripts.js +++ b/ElectronClient/tools/compileScripts.js @@ -43,6 +43,10 @@ function convertJsx(path) { module.exports = function() { convertJsx(`${__dirname}/../gui`); + convertJsx(`${__dirname}/../gui/SideBar`); + convertJsx(`${__dirname}/../gui/MainScreen`); + convertJsx(`${__dirname}/../gui/Header`); + convertJsx(`${__dirname}/../gui/NoteList`); convertJsx(`${__dirname}/../plugins`); const libContent = [ diff --git a/ReactNativeClient/lib/commands/historyBackward.ts b/ReactNativeClient/lib/commands/historyBackward.ts new file mode 100644 index 000000000..4140d5c8e --- /dev/null +++ b/ReactNativeClient/lib/commands/historyBackward.ts @@ -0,0 +1,29 @@ +import { utils, CommandRuntime, CommandDeclaration } from '../services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'historyBackward', + label: () => _('Back'), + iconName: 'fa-arrow-left', +}; + +interface Props { + backwardHistoryNotes: any[], +} + +export const runtime = ():CommandRuntime => { + return { + execute: async (props:Props) => { + if (!props.backwardHistoryNotes.length) return; + utils.store.dispatch({ + type: 'HISTORY_BACKWARD', + }); + }, + isEnabled: (props:Props) => { + return props.backwardHistoryNotes.length > 0; + }, + mapStateToProps: (state:any) => { + return { backwardHistoryNotes: state.backwardHistoryNotes }; + }, + }; +}; diff --git a/ReactNativeClient/lib/commands/historyForward.ts b/ReactNativeClient/lib/commands/historyForward.ts new file mode 100644 index 000000000..945120bbc --- /dev/null +++ b/ReactNativeClient/lib/commands/historyForward.ts @@ -0,0 +1,29 @@ +import { utils, CommandRuntime, CommandDeclaration } from '../services/CommandService'; +const { _ } = require('lib/locale'); + +export const declaration:CommandDeclaration = { + name: 'historyForward', + label: () => _('Forward'), + iconName: 'fa-arrow-right', +}; + +interface Props { + forwardHistoryNotes: any[], +} + +export const runtime = ():CommandRuntime => { + return { + execute: async (props:Props) => { + if (!props.forwardHistoryNotes.length) return; + utils.store.dispatch({ + type: 'HISTORY_FORWARD', + }); + }, + isEnabled: (props:Props) => { + return props.forwardHistoryNotes.length > 0; + }, + mapStateToProps: (state:any) => { + return { forwardHistoryNotes: state.forwardHistoryNotes }; + }, + }; +}; diff --git a/ReactNativeClient/lib/commands/synchronize.ts b/ReactNativeClient/lib/commands/synchronize.ts new file mode 100644 index 000000000..c2ebb150f --- /dev/null +++ b/ReactNativeClient/lib/commands/synchronize.ts @@ -0,0 +1,55 @@ +import { utils, CommandRuntime, CommandDeclaration } from '../services/CommandService'; +const { _ } = require('lib/locale'); +const { reg } = require('lib/registry.js'); + +export const declaration:CommandDeclaration = { + name: 'synchronize', + label: () => _('Synchronize'), + iconName: 'fa-sync-alt', +}; + +export const runtime = ():CommandRuntime => { + return { + execute: async ({ syncStarted }:any) => { + const action = syncStarted ? 'cancel' : 'start'; + + if (!(await reg.syncTarget().isAuthenticated())) { + if (reg.syncTarget().authRouteName()) { + utils.store.dispatch({ + type: 'NAV_GO', + routeName: reg.syncTarget().authRouteName(), + }); + return 'auth'; + } + + reg.logger().info('Not authentified with sync target - please check your credential.'); + return 'error'; + } + + let sync = null; + try { + sync = await reg.syncTarget().synchronizer(); + } catch (error) { + reg.logger().info('Could not acquire synchroniser:'); + reg.logger().info(error); + return 'error'; + } + + if (action == 'cancel') { + sync.cancel(); + return 'cancel'; + } else { + reg.scheduleSync(0); + return 'sync'; + } + }, + isEnabled: (props:any) => { + return !props.syncStarted; + }, + mapStateToProps: (state:any):any => { + return { + syncStarted: state.syncStarted, + }; + }, + }; +}; diff --git a/ElectronClient/eventManager.js b/ReactNativeClient/lib/eventManager.js similarity index 100% rename from ElectronClient/eventManager.js rename to ReactNativeClient/lib/eventManager.js diff --git a/ReactNativeClient/lib/hooks/useEffectDebugger.ts b/ReactNativeClient/lib/hooks/useEffectDebugger.ts new file mode 100644 index 000000000..b490ba30d --- /dev/null +++ b/ReactNativeClient/lib/hooks/useEffectDebugger.ts @@ -0,0 +1,27 @@ +import usePrevious from './usePrevious'; +import { useEffect } from 'react'; + +export default function useEffectDebugger(effectHook:any, dependencies:any, dependencyNames:any[] = []) { + const previousDeps = usePrevious(dependencies, []); + + const changedDeps = dependencies.reduce((accum:any, dependency:any, index:any) => { + if (dependency !== previousDeps[index]) { + const keyName = dependencyNames[index] || index; + return { + ...accum, + [keyName]: { + before: previousDeps[index], + after: dependency, + }, + }; + } + + return accum; + }, {}); + + if (Object.keys(changedDeps).length) { + console.log('[use-effet-debugger] ', changedDeps); + } + + useEffect(effectHook, dependencies); +} diff --git a/ReactNativeClient/lib/models/Note.js b/ReactNativeClient/lib/models/Note.js index cc0fbfeeb..ede96c417 100644 --- a/ReactNativeClient/lib/models/Note.js +++ b/ReactNativeClient/lib/models/Note.js @@ -265,7 +265,7 @@ class Note extends BaseItem { includeTimestamps: true, }, options); - const output = ['id', 'title', 'is_todo', 'todo_completed', 'parent_id', 'encryption_applied', 'order']; + const output = ['id', 'title', 'is_todo', 'todo_completed', 'todo_due', 'parent_id', 'encryption_applied', 'order', 'markup_language']; if (options.includeTimestamps) { output.push('updated_time'); diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index d141ed99c..f5f22fbb9 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -2,6 +2,7 @@ const Note = require('lib/models/Note.js'); const Folder = require('lib/models/Folder.js'); const ArrayUtils = require('lib/ArrayUtils.js'); const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids'); +const CommandService = require('lib/services/CommandService').default; const defaultState = { notes: [], @@ -77,6 +78,10 @@ const cacheEnabledOutput = (key, output) => { return derivedStateCache_[key]; }; +stateUtils.hasOneSelectedNote = function(state) { + return state.selectedNoteIds.length === 1; +}; + stateUtils.notesOrder = function(stateSettings) { if (stateSettings['notes.sortOrder.field'] === 'order') { return cacheEnabledOutput('notesOrder', [ @@ -1016,6 +1021,8 @@ const reducer = (state = defaultState, action) => { newState = handleHistory(newState, action); } + CommandService.instance().scheduleMapStateToProps(newState); + return newState; }; diff --git a/ReactNativeClient/lib/services/CommandService.ts b/ReactNativeClient/lib/services/CommandService.ts new file mode 100644 index 000000000..bfcdbda0d --- /dev/null +++ b/ReactNativeClient/lib/services/CommandService.ts @@ -0,0 +1,289 @@ +const BaseService = require('lib/services/BaseService'); +const eventManager = require('lib/eventManager'); + +export interface CommandRuntime { + execute(props:any):void + isEnabled?(props:any):boolean + mapStateToProps?(state:any):any + // Used for the (optional) toolbar button title + title?(props:any):string, + props?:any +} + +export interface CommandDeclaration { + name: string + // Used for the menu item label, and toolbar button tooltip + label?():string, + iconName?: string, + // Same as `role` key in Electron MenuItem: + // https://www.electronjs.org/docs/api/menu-item#new-menuitemoptions + // Note that due to a bug in Electron, menu items with a role cannot + // be disabled. + role?: string, +} + +export interface Command { + declaration: CommandDeclaration, + runtime?: CommandRuntime, +} + +interface Commands { + [key:string]: Command; +} + +interface ReduxStore { + dispatch(action:any):void; + getState():any; +} + +interface Utils { + store: ReduxStore; +} + +export const utils:Utils = { + store: { + dispatch: () => {}, + getState: () => {}, + }, +}; + +interface CommandByNameOptions { + mustExist?:boolean, + runtimeMustBeRegistered?:boolean, +} + +interface CommandState { + title: string, + enabled: boolean, +} + +interface CommandStates { + [key:string]: CommandState +} + +export default class CommandService extends BaseService { + + private static instance_:CommandService; + + static instance():CommandService { + if (this.instance_) return this.instance_; + this.instance_ = new CommandService(); + return this.instance_; + } + + private commands_:Commands = {}; + private commandPreviousStates_:CommandStates = {}; + private mapStateToPropsIID_:any = null; + + initialize(store:any) { + utils.store = store; + } + + public on(eventName:string, callback:Function) { + eventManager.on(eventName, callback); + } + + public off(eventName:string, callback:Function) { + eventManager.off(eventName, callback); + } + + private propsHaveChanged(previous:any, next:any) { + if (!previous && next) return true; + + for (const n in previous) { + if (previous[n] !== next[n]) return true; + } + + return false; + } + + scheduleMapStateToProps(state:any) { + if (this.mapStateToPropsIID_) clearTimeout(this.mapStateToPropsIID_); + + this.mapStateToPropsIID_ = setTimeout(() => { + this.mapStateToProps(state); + }, 50); + } + + private mapStateToProps(state:any) { + const newState = state; + + const changedCommands:any = {}; + + for (const name in this.commands_) { + const command = this.commands_[name]; + if (!command.runtime || !command.runtime.mapStateToProps) continue; + const newProps = command.runtime.mapStateToProps(state); + + const haveChanged = this.propsHaveChanged(command.runtime.props, newProps); + + if (haveChanged) { + const previousState = this.commandPreviousStates_[name]; + + command.runtime.props = newProps; + + const newState:CommandState = { + enabled: this.isEnabled(name), + title: this.title(name), + }; + + if (!previousState || previousState.title !== newState.title || previousState.enabled !== newState.enabled) { + changedCommands[name] = newState; + } + + this.commandPreviousStates_[name] = newState; + } + } + + if (Object.keys(changedCommands).length) { + eventManager.emit('commandsEnabledStateChange', { commands: changedCommands }); + } + + return newState; + } + + private commandByName(name:string, options:CommandByNameOptions = null):Command { + options = { + mustExist: true, + runtimeMustBeRegistered: false, + }; + + const command = this.commands_[name]; + + if (!command) { + if (options.mustExist) throw new Error(`Command not found: ${name}. Make sure the declaration has been registered.`); + return null; + } + + if (options.runtimeMustBeRegistered && !command.runtime) throw new Error(`Runtime is not registered for command ${name}`); + return command; + } + + registerDeclaration(declaration:CommandDeclaration) { + // if (this.commands_[declaration.name]) throw new Error(`There is already a command with name ${declaration.name}`); + + declaration = { ...declaration }; + if (!declaration.label) declaration.label = () => ''; + if (!declaration.iconName) declaration.iconName = ''; + + // In TypeScript it's not an issue, but in JavaScript it's easy to accidentally set the label + // to a string instead of a function, and it will cause strange errors that are hard to debug. + // So here check early that we have the right type. + if (typeof declaration.label !== 'function') throw new Error(`declaration.label must be a function: ${declaration.name}`); + + this.commands_[declaration.name] = { + declaration: declaration, + }; + } + + registerRuntime(commandName:string, runtime:CommandRuntime) { + // console.info('CommandService::registerRuntime:', commandName); + + if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`); + + const command = this.commandByName(commandName); + // if (command.runtime) throw new Error(`Runtime is already registered for command: ${commandName}`); + + runtime = Object.assign({}, runtime); + if (!runtime.isEnabled) runtime.isEnabled = () => true; + if (!runtime.title) runtime.title = () => null; + command.runtime = runtime; + } + + componentRegisterCommands(component:any, commands:any[]) { + for (const command of commands) { + CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component)); + } + } + + componentUnregisterCommands(commands:any[]) { + for (const command of commands) { + CommandService.instance().unregisterRuntime(command.declaration.name); + } + } + + unregisterRuntime(commandName:string) { + // console.info('CommandService::unregisterRuntime:', commandName); + + const command = this.commandByName(commandName, { mustExist: false }); + if (!command || !command.runtime) return; + delete command.runtime; + } + + execute(commandName:string, args:any = null) { + console.info('CommandService::execute:', commandName, args); + + const command = this.commandByName(commandName); + command.runtime.execute(args ? args : {}); + } + + scheduleExecute(commandName:string, args:any = null) { + setTimeout(() => { + this.execute(commandName, args); + }, 10); + } + + isEnabled(commandName:string):boolean { + const command = this.commandByName(commandName); + if (!command || !command.runtime) return false; + return command.runtime.props ? command.runtime.isEnabled(command.runtime.props ? command.runtime.props : {}) : true; + } + + title(commandName:string):string { + const command = this.commandByName(commandName); + if (!command || !command.runtime) return null; + return command.runtime.props ? command.runtime.title(command.runtime.props ? command.runtime.props : {}) : null; + } + + private extractExecuteArgs(command:Command, executeArgs:any) { + if (executeArgs) return executeArgs; + if (!command.runtime) throw new Error(`Command: ${command.declaration.name}: Runtime is not defined - make sure it has been registered.`); + if (command.runtime.props) return command.runtime.props; + return {}; + } + + commandToToolbarButton(commandName:string, executeArgs:any = null) { + const command = this.commandByName(commandName, { runtimeMustBeRegistered: true }); + + return { + tooltip: command.declaration.label(), + iconName: command.declaration.iconName, + enabled: this.isEnabled(commandName), + onClick: () => { + this.execute(commandName, this.extractExecuteArgs(command, executeArgs)); + }, + title: this.title(commandName), + }; + } + + commandToMenuItem(commandName:string, accelerator:string = null, executeArgs:any = null) { + const command = this.commandByName(commandName); + + const item:any = { + id: command.declaration.name, + label: command.declaration.label(), + click: () => { + this.execute(commandName, this.extractExecuteArgs(command, executeArgs)); + }, + }; + + if (accelerator) item.accelerator = accelerator; + if (command.declaration.role) item.role = command.declaration.role; + + return item; + } + + commandsEnabledState(previousState:any = null):any { + const output:any = {}; + + for (const name in this.commands_) { + const enabled = this.isEnabled(name); + if (!previousState || previousState[name] !== enabled) { + output[name] = enabled; + } + } + + return output; + } + +} diff --git a/joplin.code-workspace b/joplin.code-workspace index 61b523be3..b482b4e04 100644 --- a/joplin.code-workspace +++ b/joplin.code-workspace @@ -313,7 +313,48 @@ "D:/Web/www/nextcloud/apps/joplin/Tools/**/github_oauth_token.txt": true, "D:/Web/www/nextcloud/apps/joplin/Tools/**/node_modules/": true, "D:/Web/www/nextcloud/apps/joplin/**/vendor/": true, - "D:/Web/www/nextcloud/apps/joplin/**/dist/": true + "D:/Web/www/nextcloud/apps/joplin/**/dist/": true, + "ReactNativeClient/lib/commands/newNote.js": true, + "ReactNativeClient/lib/commands/newTodo.js": true, + "ReactNativeClient/lib/services/CommandService.js": true, + "ElectronClient/gui/ErrorBoundary.js": true, + "ElectronClient/gui/MainScreen/commands/editAlarm.js": true, + "ElectronClient/gui/MainScreen/commands/exportPdf.js": true, + "ElectronClient/gui/MainScreen/commands/hideModalMessage.js": true, + "ElectronClient/gui/MainScreen/commands/moveToFolder.js": true, + "ElectronClient/gui/MainScreen/commands/newNote.js": true, + "ElectronClient/gui/MainScreen/commands/newNotebook.js": true, + "ElectronClient/gui/MainScreen/commands/newTodo.js": true, + "ElectronClient/gui/MainScreen/commands/print.js": true, + "ElectronClient/gui/MainScreen/commands/renameFolder.js": true, + "ElectronClient/gui/MainScreen/commands/renameTag.js": true, + "ElectronClient/gui/MainScreen/commands/search.js": true, + "ElectronClient/gui/MainScreen/commands/selectTemplate.js": true, + "ElectronClient/gui/MainScreen/commands/setTags.js": true, + "ElectronClient/gui/MainScreen/commands/showModalMessage.js": true, + "ElectronClient/gui/MainScreen/commands/showNoteContentProperties.js": true, + "ElectronClient/gui/MainScreen/commands/showNoteProperties.js": true, + "ElectronClient/gui/MainScreen/commands/showShareNoteDialog.js": true, + "ElectronClient/gui/MainScreen/commands/toggleNoteList.js": true, + "ElectronClient/gui/MainScreen/commands/toggleSidebar.js": true, + "ElectronClient/gui/MainScreen/commands/toggleVisiblePanes.js": true, + "./ElectronClient/**/*.min.js": true, + "ElectronClient/commands/focusElement.js": true, + "ElectronClient/gui/Header/commands/focusSearch.js": true, + "ElectronClient/gui/NoteEditor/commands/editorCommandDeclarations.js": true, + "ElectronClient/gui/NoteEditor/commands/focusElementNoteBody.js": true, + "ElectronClient/gui/NoteEditor/commands/focusElementNoteTitle.js": true, + "ElectronClient/gui/NoteEditor/commands/showLocalSearch.js": true, + "ElectronClient/gui/NoteEditor/commands/startExternalEditing.js": true, + "ElectronClient/gui/NoteEditor/commands/stopExternalEditing.js": true, + "ElectronClient/gui/NoteList/commands/focusElementNoteList.js": true, + "ElectronClient/gui/SideBar/commands/focusElementSideBar.js": true, + "ReactNativeClient/lib/commands/synchronize.js": true, + "ElectronClient/commands/startExternalEditing.js": true, + "ElectronClient/commands/stopExternalEditing.js": true, + "ElectronClient/gui/NoteEditor/commands/showRevisions.js": true, + "ReactNativeClient/lib/commands/historyBackward.js": true, + "ReactNativeClient/lib/commands/historyForward.js": true }, "spellright.language": [ "en"