diff --git a/.eslintignore b/.eslintignore index bc931e63e09..bec45388aeb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -154,6 +154,7 @@ packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js packages/app-desktop/commands/index.js +packages/app-desktop/commands/openNoteInNewWindow.js packages/app-desktop/commands/openProfileDirectory.js packages/app-desktop/commands/renderMarkup.test.js packages/app-desktop/commands/renderMarkup.js @@ -207,60 +208,14 @@ packages/app-desktop/gui/KeymapConfig/styles/index.js packages/app-desktop/gui/KeymapConfig/utils/getLabel.js packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js -packages/app-desktop/gui/MainScreen/MainScreen.js -packages/app-desktop/gui/MainScreen/commands/addProfile.js -packages/app-desktop/gui/MainScreen/commands/commandPalette.js -packages/app-desktop/gui/MainScreen/commands/deleteFolder.js -packages/app-desktop/gui/MainScreen/commands/duplicateNote.js -packages/app-desktop/gui/MainScreen/commands/editAlarm.js -packages/app-desktop/gui/MainScreen/commands/exportPdf.js -packages/app-desktop/gui/MainScreen/commands/gotoAnything.js -packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js -packages/app-desktop/gui/MainScreen/commands/index.js -packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js -packages/app-desktop/gui/MainScreen/commands/moveToFolder.js -packages/app-desktop/gui/MainScreen/commands/newFolder.js -packages/app-desktop/gui/MainScreen/commands/newNote.js -packages/app-desktop/gui/MainScreen/commands/newSubFolder.js -packages/app-desktop/gui/MainScreen/commands/newTodo.js -packages/app-desktop/gui/MainScreen/commands/openFolder.js -packages/app-desktop/gui/MainScreen/commands/openFolderDialog.js -packages/app-desktop/gui/MainScreen/commands/openItem.js -packages/app-desktop/gui/MainScreen/commands/openNote.js -packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js -packages/app-desktop/gui/MainScreen/commands/openTag.js -packages/app-desktop/gui/MainScreen/commands/print.js -packages/app-desktop/gui/MainScreen/commands/renameFolder.js -packages/app-desktop/gui/MainScreen/commands/renameTag.js -packages/app-desktop/gui/MainScreen/commands/resetLayout.js -packages/app-desktop/gui/MainScreen/commands/restoreFolder.js -packages/app-desktop/gui/MainScreen/commands/restoreNote.js -packages/app-desktop/gui/MainScreen/commands/revealResourceFile.js -packages/app-desktop/gui/MainScreen/commands/search.js -packages/app-desktop/gui/MainScreen/commands/setTags.js -packages/app-desktop/gui/MainScreen/commands/showModalMessage.js -packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js -packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js -packages/app-desktop/gui/MainScreen/commands/showPrompt.js -packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js -packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js -packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.test.js -packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js -packages/app-desktop/gui/MainScreen/commands/toggleEditors.js -packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js -packages/app-desktop/gui/MainScreen/commands/toggleMenuBar.js -packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js -packages/app-desktop/gui/MainScreen/commands/toggleNoteType.js -packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js -packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js -packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js -packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js -packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js +packages/app-desktop/gui/MainScreen.js packages/app-desktop/gui/MasterPasswordDialog/Dialog.js packages/app-desktop/gui/MenuBar.js packages/app-desktop/gui/MultiNoteActions.js packages/app-desktop/gui/Navigator.js +packages/app-desktop/gui/NewWindowOrIFrame.js packages/app-desktop/gui/NoteContentPropertiesDialog.js +packages/app-desktop/gui/NoteEditor/EditorWindow.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.test.js @@ -321,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.test.js @@ -453,7 +409,66 @@ packages/app-desktop/gui/ToolbarButton/ToolbarButton.js packages/app-desktop/gui/ToolbarSpace.js packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/UpdateNotification/UpdateNotification.js +packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js +packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteList.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteType.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderField.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderReverse.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js +packages/app-desktop/gui/WindowCommandsAndDialogs/types.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js packages/app-desktop/gui/dialogs.js +packages/app-desktop/gui/hooks/useDocument.js packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useElementHeight.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js @@ -542,6 +557,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js packages/app-desktop/utils/isSafeToOpen.js packages/app-desktop/utils/restartInSafeModeFromMain.test.js packages/app-desktop/utils/restartInSafeModeFromMain.js +packages/app-desktop/utils/window/types.js packages/app-mobile/PluginAssetsLoader.js packages/app-mobile/commands/index.js packages/app-mobile/commands/newNote.test.js @@ -992,6 +1008,8 @@ packages/lib/geolocation-node.js packages/lib/hooks/useAsyncEffect.js packages/lib/hooks/useElementSize.js packages/lib/hooks/useEventListener.js +packages/lib/hooks/useNowEffect.test.js +packages/lib/hooks/useNowEffect.js packages/lib/hooks/usePlugin.js packages/lib/hooks/usePrevious.js packages/lib/hooks/useQueuedAsyncEffect.test.js diff --git a/.gitignore b/.gitignore index 796ad6f4313..8e17b5591c7 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportNotes.js packages/app-desktop/commands/focusElement.js packages/app-desktop/commands/index.js +packages/app-desktop/commands/openNoteInNewWindow.js packages/app-desktop/commands/openProfileDirectory.js packages/app-desktop/commands/renderMarkup.test.js packages/app-desktop/commands/renderMarkup.js @@ -184,60 +185,14 @@ packages/app-desktop/gui/KeymapConfig/styles/index.js packages/app-desktop/gui/KeymapConfig/utils/getLabel.js packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js -packages/app-desktop/gui/MainScreen/MainScreen.js -packages/app-desktop/gui/MainScreen/commands/addProfile.js -packages/app-desktop/gui/MainScreen/commands/commandPalette.js -packages/app-desktop/gui/MainScreen/commands/deleteFolder.js -packages/app-desktop/gui/MainScreen/commands/duplicateNote.js -packages/app-desktop/gui/MainScreen/commands/editAlarm.js -packages/app-desktop/gui/MainScreen/commands/exportPdf.js -packages/app-desktop/gui/MainScreen/commands/gotoAnything.js -packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js -packages/app-desktop/gui/MainScreen/commands/index.js -packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js -packages/app-desktop/gui/MainScreen/commands/moveToFolder.js -packages/app-desktop/gui/MainScreen/commands/newFolder.js -packages/app-desktop/gui/MainScreen/commands/newNote.js -packages/app-desktop/gui/MainScreen/commands/newSubFolder.js -packages/app-desktop/gui/MainScreen/commands/newTodo.js -packages/app-desktop/gui/MainScreen/commands/openFolder.js -packages/app-desktop/gui/MainScreen/commands/openFolderDialog.js -packages/app-desktop/gui/MainScreen/commands/openItem.js -packages/app-desktop/gui/MainScreen/commands/openNote.js -packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js -packages/app-desktop/gui/MainScreen/commands/openTag.js -packages/app-desktop/gui/MainScreen/commands/print.js -packages/app-desktop/gui/MainScreen/commands/renameFolder.js -packages/app-desktop/gui/MainScreen/commands/renameTag.js -packages/app-desktop/gui/MainScreen/commands/resetLayout.js -packages/app-desktop/gui/MainScreen/commands/restoreFolder.js -packages/app-desktop/gui/MainScreen/commands/restoreNote.js -packages/app-desktop/gui/MainScreen/commands/revealResourceFile.js -packages/app-desktop/gui/MainScreen/commands/search.js -packages/app-desktop/gui/MainScreen/commands/setTags.js -packages/app-desktop/gui/MainScreen/commands/showModalMessage.js -packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.js -packages/app-desktop/gui/MainScreen/commands/showNoteProperties.js -packages/app-desktop/gui/MainScreen/commands/showPrompt.js -packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js -packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js -packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.test.js -packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js -packages/app-desktop/gui/MainScreen/commands/toggleEditors.js -packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js -packages/app-desktop/gui/MainScreen/commands/toggleMenuBar.js -packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js -packages/app-desktop/gui/MainScreen/commands/toggleNoteType.js -packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js -packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js -packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js -packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js -packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js +packages/app-desktop/gui/MainScreen.js packages/app-desktop/gui/MasterPasswordDialog/Dialog.js packages/app-desktop/gui/MenuBar.js packages/app-desktop/gui/MultiNoteActions.js packages/app-desktop/gui/Navigator.js +packages/app-desktop/gui/NewWindowOrIFrame.js packages/app-desktop/gui/NoteContentPropertiesDialog.js +packages/app-desktop/gui/NoteEditor/EditorWindow.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.test.js @@ -298,6 +253,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.test.js @@ -430,7 +386,66 @@ packages/app-desktop/gui/ToolbarButton/ToolbarButton.js packages/app-desktop/gui/ToolbarSpace.js packages/app-desktop/gui/TrashNotification/TrashNotification.js packages/app-desktop/gui/UpdateNotification/UpdateNotification.js +packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js +packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteList.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteType.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderField.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderReverse.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js +packages/app-desktop/gui/WindowCommandsAndDialogs/types.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js +packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js packages/app-desktop/gui/dialogs.js +packages/app-desktop/gui/hooks/useDocument.js packages/app-desktop/gui/hooks/useEffectDebugger.js packages/app-desktop/gui/hooks/useElementHeight.js packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js @@ -519,6 +534,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js packages/app-desktop/utils/isSafeToOpen.js packages/app-desktop/utils/restartInSafeModeFromMain.test.js packages/app-desktop/utils/restartInSafeModeFromMain.js +packages/app-desktop/utils/window/types.js packages/app-mobile/PluginAssetsLoader.js packages/app-mobile/commands/index.js packages/app-mobile/commands/newNote.test.js @@ -969,6 +985,8 @@ packages/lib/geolocation-node.js packages/lib/hooks/useAsyncEffect.js packages/lib/hooks/useElementSize.js packages/lib/hooks/useEventListener.js +packages/lib/hooks/useNowEffect.test.js +packages/lib/hooks/useNowEffect.js packages/lib/hooks/usePlugin.js packages/lib/hooks/usePrevious.js packages/lib/hooks/useQueuedAsyncEffect.test.js diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index 961f4b02a37..143f2664776 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -17,6 +17,8 @@ import { _ } from '@joplin/lib/locale'; import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain'; import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols'; import { clearTimeout, setTimeout } from 'timers'; +import { resolve } from 'path'; +import { defaultWindowId } from '@joplin/lib/reducer'; interface RendererProcessQuitReply { canClose: boolean; @@ -27,21 +29,30 @@ interface PluginWindows { [key: string]: any; } -export default class ElectronAppWrapper { +type SecondaryWindowId = string; +interface SecondaryWindowData { + electronId: number; +} +export default class ElectronAppWrapper { private logger_: Logger = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private electronApp_: any; private env_: string; private isDebugMode_: boolean; private profilePath_: string; + private win_: BrowserWindow = null; + private mainWindowHidden_ = true; + private pluginWindows_: PluginWindows = {}; + private secondaryWindows_: Map = new Map(); + private willQuitApp_ = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private tray_: any = null; private buildDir_: string = null; private rendererProcessQuitReply_: RendererProcessQuitReply = null; - private pluginWindows_: PluginWindows = {}; + private initialCallbackUrl_: string = null; private updaterService_: AutoUpdaterService = null; private customProtocolHandler_: CustomProtocolHandler = null; @@ -68,10 +79,26 @@ export default class ElectronAppWrapper { return this.logger_; } - public window() { + public mainWindow() { return this.win_; } + public activeWindow() { + return BrowserWindow.getFocusedWindow() ?? this.win_; + } + + public windowById(joplinId: string) { + if (joplinId === defaultWindowId) { + return this.mainWindow(); + } + + const windowData = this.secondaryWindows_.get(joplinId); + if (windowData !== undefined) { + return BrowserWindow.fromId(windowData.electronId); + } + return null; + } + public env() { return this.env_; } @@ -210,6 +237,15 @@ export default class ElectronAppWrapper { } }); + this.mainWindowHidden_ = !windowOptions.show; + this.win_.on('hide', () => { + this.mainWindowHidden_ = true; + }); + + this.win_.on('show', () => { + this.mainWindowHidden_ = false; + }); + void this.win_.loadURL(url.format({ pathname: path.join(__dirname, 'index.html'), protocol: 'file:', @@ -249,6 +285,11 @@ export default class ElectronAppWrapper { // Script-controlled pages: Used for opening notes in new windows return { action: 'allow', + overrideBrowserWindowOptions: { + webPreferences: { + preload: resolve(__dirname, './utils/window/secondaryWindowPreload.js'), + }, + }, }; } else if (event.url.match(/^https?:\/\//)) { void bridge().openExternal(event.url); @@ -281,7 +322,8 @@ export default class ElectronAppWrapper { this.hide(); } } else { - if (this.trayShown() && !this.willQuitApp_) { + const hasBackgroundWindows = this.secondaryWindows_.size > 0; + if ((hasBackgroundWindows || this.trayShown()) && !this.willQuitApp_) { event.preventDefault(); this.win_.hide(); } else { @@ -311,6 +353,23 @@ export default class ElectronAppWrapper { } }); + ipcMain.on('secondary-window-added', (event, windowId: string) => { + const window = BrowserWindow.fromWebContents(event.sender); + const electronWindowId = window?.id; + this.secondaryWindows_.set(windowId, { electronId: electronWindowId }); + + window.once('close', () => { + this.secondaryWindows_.delete(windowId); + + const allSecondaryWindowsClosed = this.secondaryWindows_.size === 0; + const mainWindowVisuallyClosed = this.mainWindowHidden_; + if (allSecondaryWindowsClosed && mainWindowVisuallyClosed && !this.trayShown()) { + // Gracefully quit the app if the user has closed all windows + this.win_.close(); + } + }); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied ipcMain.on('asynchronous-message', (_event: any, message: string, args: any) => { if (message === 'appCloseReply') { @@ -442,11 +501,11 @@ export default class ElectronAppWrapper { this.tray_.setContextMenu(contextMenu); this.tray_.on('click', () => { - if (!this.window()) { + if (!this.mainWindow()) { console.warn('The window object was not available during the click event from tray icon'); return; } - this.window().show(); + this.mainWindow().show(); }); } catch (error) { console.error('Cannot create tray', error); @@ -473,7 +532,7 @@ export default class ElectronAppWrapper { // Someone tried to open a second instance - focus our window instead // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied this.electronApp_.on('second-instance', (_e: any, argv: string[]) => { - const win = this.window(); + const win = this.mainWindow(); if (!win) return; if (win.isMinimized()) win.restore(); win.show(); diff --git a/packages/app-desktop/app.reducer.test.ts b/packages/app-desktop/app.reducer.test.ts index d24b374a770..8f6f87e8bcc 100644 --- a/packages/app-desktop/app.reducer.test.ts +++ b/packages/app-desktop/app.reducer.test.ts @@ -1,4 +1,4 @@ -import { AppState } from './app.reducer'; +import { AppState, createAppDefaultWindowState } from './app.reducer'; import appReducer, { createAppDefaultState } from './app.reducer'; describe('app.reducer', () => { @@ -47,4 +47,28 @@ describe('app.reducer', () => { ]); }); + it('showing a dialog in one window should hide dialogs with the same ID in background windows', () => { + const state: AppState = { + ...createAppDefaultState({}, {}), + backgroundWindows: { + testWindow: { + ...createAppDefaultWindowState(), + windowId: 'testWindow', + + visibleDialogs: { + testDialog: true, + }, + }, + }, + }; + + const newState = appReducer(state, { + type: 'VISIBLE_DIALOGS_ADD', + name: 'testDialog', + }); + + expect(newState.backgroundWindows.testWindow.visibleDialogs).toEqual({}); + expect(newState.visibleDialogs).toEqual({ testDialog: true }); + }); + }); diff --git a/packages/app-desktop/app.reducer.ts b/packages/app-desktop/app.reducer.ts index 35711b19b2e..3253ceee0f5 100644 --- a/packages/app-desktop/app.reducer.ts +++ b/packages/app-desktop/app.reducer.ts @@ -1,6 +1,6 @@ import produce from 'immer'; import Setting from '@joplin/lib/models/Setting'; -import { defaultState, State } from '@joplin/lib/reducer'; +import { defaultState, defaultWindowState, State, WindowState } from '@joplin/lib/reducer'; import iterateItems from './gui/ResizableLayout/utils/iterateItems'; import { LayoutItem } from './gui/ResizableLayout/utils/types'; import validateLayout from './gui/ResizableLayout/utils/validateLayout'; @@ -30,56 +30,89 @@ export interface EditorScrollPercents { [noteId: string]: number; } -export interface AppState extends State { +export interface VisibleDialogs { + [dialogKey: string]: boolean; +} + +export interface AppWindowState extends WindowState { + noteVisiblePanes: string[]; + editorCodeView: boolean; + visibleDialogs: VisibleDialogs; + dialogs: AppStateDialog[]; + devToolsVisible: boolean; +} + +interface BackgroundWindowStates { + [windowId: string]: AppWindowState; +} + +export interface AppState extends State, AppWindowState { + backgroundWindows: BackgroundWindowStates; + route: AppStateRoute; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied navHistory: any[]; - noteVisiblePanes: string[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied windowContentSize: any; watchedNoteFiles: string[]; lastEditorScrollPercents: EditorScrollPercents; - devToolsVisible: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - visibleDialogs: any; // empty object if no dialog is visible. Otherwise contains the list of visible dialogs. focusedField: string; layoutMoveMode: boolean; startupPluginsLoaded: boolean; + modalOverlayMessage: string|null; // Extra reducer keys go here // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied watchedResources: any; mainLayout: LayoutItem; - dialogs: AppStateDialog[]; isResettingLayout: boolean; } +export const createAppDefaultWindowState = (): AppWindowState => { + return { + ...defaultWindowState, + visibleDialogs: {}, + dialogs: [], + noteVisiblePanes: ['editor', 'viewer'], + editorCodeView: true, + devToolsVisible: false, + }; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export function createAppDefaultState(windowContentSize: any, resourceEditWatcherDefaultState: any): AppState { return { ...defaultState, + ...createAppDefaultWindowState(), route: { type: 'NAV_GO', routeName: 'Main', props: {}, }, navHistory: [], - noteVisiblePanes: ['editor', 'viewer'], windowContentSize, // bridge().windowContentSize(), watchedNoteFiles: [], lastEditorScrollPercents: {}, - devToolsVisible: false, visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs. focusedField: null, layoutMoveMode: false, mainLayout: null, startupPluginsLoaded: false, - dialogs: [], isResettingLayout: false, + modalOverlayMessage: null, ...resourceEditWatcherDefaultState, }; } +const hideBackgroundDialogsWithId = produce((state: AppState, id: string) => { + for (const windowId of Object.keys(state.backgroundWindows)) { + const win = state.backgroundWindows[windowId]; + if (id in win.visibleDialogs) { + delete win.visibleDialogs[id]; + } + } +}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied export default function(state: AppState, action: any) { let newState = state; @@ -171,9 +204,17 @@ export default function(state: AppState, action: any) { break; case 'NOTE_VISIBLE_PANES_SET': + newState = { + ...state, + noteVisiblePanes: action.panes, + }; + break; - newState = { ...state }; - newState.noteVisiblePanes = action.panes; + case 'EDITOR_CODE_VIEW_CHANGE': + newState = { + ...state, + editorCodeView: action.value, + }; break; case 'MAIN_LAYOUT_SET': @@ -217,6 +258,14 @@ export default function(state: AppState, action: any) { break; + case 'SHOW_MODAL_MESSAGE': + newState = { ...newState, modalOverlayMessage: action.message }; + break; + + case 'HIDE_MODAL_MESSAGE': + newState = { ...newState, modalOverlayMessage: null }; + break; + case 'NOTE_FILE_WATCHER_ADD': if (newState.watchedNoteFiles.indexOf(action.id) < 0) { @@ -272,12 +321,14 @@ export default function(state: AppState, action: any) { newState = { ...state }; newState.visibleDialogs = { ...newState.visibleDialogs }; newState.visibleDialogs[action.name] = true; + newState = hideBackgroundDialogsWithId(newState, action.name); break; case 'VISIBLE_DIALOGS_REMOVE': newState = { ...state }; newState.visibleDialogs = { ...newState.visibleDialogs }; delete newState.visibleDialogs[action.name]; + newState = hideBackgroundDialogsWithId(newState, action.name); break; case 'FOCUS_SET': diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 330a40f6de9..265f110edf2 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -34,8 +34,8 @@ const Menu = bridge().Menu; const PluginManager = require('@joplin/lib/services/PluginManager'); import RevisionService from '@joplin/lib/services/RevisionService'; import MigrationService from '@joplin/lib/services/MigrationService'; -import { loadCustomCss, injectCustomStyles } from '@joplin/lib/CssUtils'; -import mainScreenCommands from './gui/MainScreen/commands/index'; +import { loadCustomCss } from '@joplin/lib/CssUtils'; +import mainScreenCommands from './gui/WindowCommandsAndDialogs/commands/index'; import noteEditorCommands from './gui/NoteEditor/commands/index'; import noteListCommands from './gui/NoteList/commands/index'; import noteListControlsCommands from './gui/NoteListControls/commands/index'; @@ -151,10 +151,6 @@ class Application extends BaseApplication { void this.setupOcrService(); } - if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'style.editor.fontFamily' || action.type === 'SETTING_UPDATE_ALL') { - this.updateEditorFont(); - } - if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'windowContentZoomFactor' || action.type === 'SETTING_UPDATE_ALL') { webFrame.setZoomFactor(Setting.value('windowContentZoomFactor') / 100); } @@ -218,7 +214,7 @@ class Application extends BaseApplication { app.destroyTray(); } else { const contextMenu = Menu.buildFromTemplate([ - { label: _('Open %s', app.electronApp().name), click: () => { app.window().show(); } }, + { label: _('Open %s', app.electronApp().name), click: () => { app.mainWindow().show(); } }, { type: 'separator' }, { label: _('Quit'), click: () => { void app.quit(); } }, ]); @@ -226,23 +222,6 @@ class Application extends BaseApplication { } } - public updateEditorFont() { - const fontFamilies = []; - if (Setting.value('style.editor.fontFamily')) fontFamilies.push(`"${Setting.value('style.editor.fontFamily')}"`); - fontFamilies.push('\'Avenir Next\', Avenir, Arial, sans-serif'); - - // The '*' and '!important' parts are necessary to make sure Russian text is displayed properly - // https://github.com/laurent22/joplin/issues/155 - // - // Note: Be careful about the specificity here. Incorrect specificity can break monospaced fonts in tables. - - const css = `.CodeMirror5 *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`; - const styleTag = document.createElement('style'); - styleTag.type = 'text/css'; - styleTag.appendChild(document.createTextNode(css)); - document.head.appendChild(styleTag); - } - public setupContextMenu() { // bridge().setupContextMenu((misspelledWord: string, dictionarySuggestions: string[]) => { // let output = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions); @@ -430,6 +409,23 @@ class Application extends BaseApplication { } } + private async setupCustomCss() { + const chromeCssPath = Setting.customCssFilePath(Setting.customCssFilenames.JOPLIN_APP); + if (await shim.fsDriver().exists(chromeCssPath)) { + this.store().dispatch({ + // Main window custom CSS + type: 'CUSTOM_CHROME_CSS_ADD', + filePath: chromeCssPath, + }); + } + + this.store().dispatch({ + // Markdown preview pane + type: 'CUSTOM_VIEWER_CSS_APPEND', + css: await loadCustomCss(Setting.customCssFilePath(Setting.customCssFilenames.RENDERED_MARKDOWN)), + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public async start(argv: string[], startOptions: StartOptions = null): Promise { // If running inside a package, the command line, instead of being "node.exe " is "joplin.exe " so @@ -444,7 +440,7 @@ class Application extends BaseApplication { if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) { reg.logger().info('app.start: doing upgradeSyncTarget action'); - bridge().window().show(); + bridge().mainWindow().show(); return { action: 'upgradeSyncTarget' }; } @@ -462,9 +458,6 @@ class Application extends BaseApplication { syncDebugLog.info(`Profile dir: ${dir}`); } - // Loads app-wide styles. (Markdown preview-specific styles loaded in app.js) - await injectCustomStyles('appStyles', Setting.customCssFilePath(Setting.customCssFilenames.JOPLIN_APP)); - this.setupAutoUpdaterService(); AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId })); @@ -541,6 +534,8 @@ class Application extends BaseApplication { items: tags, }); + await this.setupCustomCss(); + // const masterKeys = await MasterKey.all(); // this.dispatch({ @@ -583,13 +578,6 @@ class Application extends BaseApplication { ids: Setting.value('collapsedFolderIds'), }); - // Loads custom Markdown preview styles - const cssString = await loadCustomCss(Setting.customCssFilePath(Setting.customCssFilenames.RENDERED_MARKDOWN)); - this.store().dispatch({ - type: 'CUSTOM_CSS_APPEND', - css: cssString, - }); - this.store().dispatch({ type: 'NOTE_DEVTOOLS_SET', value: Setting.value('flagOpenDevTools'), @@ -602,7 +590,7 @@ class Application extends BaseApplication { if (shim.isWindows() || shim.isMac()) { const runAutoUpdateCheck = () => { if (Setting.value('autoUpdateEnabled')) { - void checkForUpdates(true, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); + void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); } }; @@ -623,9 +611,9 @@ class Application extends BaseApplication { }, 1000 * 60 * 60); if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) { - bridge().window().hide(); + bridge().mainWindow().hide(); } else { - bridge().window().show(); + bridge().mainWindow().show(); } void ShareService.instance().maintenance(); @@ -698,6 +686,15 @@ class Application extends BaseApplication { Setting.setValue('linking.extraAllowedExtensions', newExtensions); }); + window.addEventListener('focus', () => { + const currentWindowId = this.store().getState().windowId; + this.dispatch({ + type: 'WINDOW_FOCUS', + windowId: 'default', + lastWindowId: currentWindowId, + }); + }); + await this.initPluginService(); this.setupContextMenu(); diff --git a/packages/app-desktop/bridge.ts b/packages/app-desktop/bridge.ts index ea1bfc691b1..0a25fffe8b9 100644 --- a/packages/app-desktop/bridge.ts +++ b/packages/app-desktop/bridge.ts @@ -14,6 +14,7 @@ import { extname, normalize } from 'path'; import isSafeToOpen from './utils/isSafeToOpen'; import { closeSync, openSync, readSync, statSync } from 'fs'; import { KB } from '@joplin/utils/bytes'; +import { defaultWindowId } from '@joplin/lib/reducer'; interface LastSelectedPath { file: string; @@ -234,7 +235,7 @@ export class Bridge { // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied public setupContextMenu(_spellCheckerMenuItemsHandler: Function) { require('electron-context-menu')({ - allWindows: [this.window()], + allWindows: [this.mainWindow()], electronApp: this.electronApp(), @@ -259,8 +260,29 @@ export class Bridge { }); } - public window() { - return this.electronWrapper_.window(); + public mainWindow() { + return this.electronWrapper_.mainWindow(); + } + + public activeWindow() { + return this.electronWrapper_.activeWindow(); + } + + public windowById(id: string) { + return this.electronWrapper_.windowById(id); + } + + // Switches to the window with the given ID, but only if that window was not the + // last focused window + public switchToWindow(windowId: string) { + const targetWindow = this.windowById(windowId); + if (this.activeWindow() !== this.windowById(windowId)) { + targetWindow.show(); + } + } + + public switchToMainWindow() { + this.switchToWindow(defaultWindowId); } public showItemInFolder(fullPath: string) { @@ -272,36 +294,31 @@ export class Bridge { return new BrowserWindow(options); } + // Note: This provides the size of the main window. Prefer CSS where possible. public windowContentSize() { - if (!this.window()) return { width: 0, height: 0 }; - const s = this.window().getContentSize(); - return { width: s[0], height: s[1] }; - } - - public windowSize() { - if (!this.window()) return { width: 0, height: 0 }; - const s = this.window().getSize(); + if (!this.mainWindow()) return { width: 0, height: 0 }; + const s = this.mainWindow().getContentSize(); return { width: s[0], height: s[1] }; } public windowSetSize(width: number, height: number) { - if (!this.window()) return; - return this.window().setSize(width, height); + if (!this.mainWindow()) return; + return this.mainWindow().setSize(width, height); } public openDevTools() { - return this.window().webContents.openDevTools(); + return this.activeWindow().webContents.openDevTools(); } public closeDevTools() { - return this.window().webContents.closeDevTools(); + return this.activeWindow().webContents.closeDevTools(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public async showSaveDialog(options: any) { if (!options) options = {}; if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file; - const { filePath } = await dialog.showSaveDialog(this.window(), options); + const { filePath } = await dialog.showSaveDialog(this.activeWindow(), options); if (filePath) { this.lastSelectedPaths_.file = filePath; } @@ -316,7 +333,7 @@ export class Bridge { if (!('defaultPath' in options) && (this.lastSelectedPaths_ as any)[fileType]) options.defaultPath = (this.lastSelectedPaths_ as any)[fileType]; if (!('createDirectory' in options)) options.createDirectory = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const { filePaths } = await dialog.showOpenDialog(this.window(), options as any); + const { filePaths } = await dialog.showOpenDialog(this.activeWindow(), options as any); if (filePaths && filePaths.length) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied (this.lastSelectedPaths_ as any)[fileType] = dirname(filePaths[0]); @@ -327,7 +344,7 @@ export class Bridge { // Don't use this directly - call one of the showXxxxxxxMessageBox() instead // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private showMessageBox_(window: any, options: MessageDialogOptions): number { - if (!window) window = this.window(); + if (!window) window = this.activeWindow(); return dialog.showMessageBoxSync(window, { message: '', ...options }); } @@ -337,7 +354,7 @@ export class Bridge { ...options, }; - return this.showMessageBox_(this.window(), { + return this.showMessageBox_(this.activeWindow(), { type: 'error', message: message, buttons: options.buttons, @@ -350,7 +367,7 @@ export class Bridge { ...options, }; - const result = this.showMessageBox_(this.window(), { type: 'question', + const result = this.showMessageBox_(this.activeWindow(), { type: 'question', message: message, cancelId: 1, buttons: options.buttons, ...options }); @@ -360,7 +377,7 @@ export class Bridge { /* returns the index of the clicked button */ public showMessageBox(message: string, options: MessageDialogOptions = {}) { - const result = this.showMessageBox_(this.window(), { type: 'question', + const result = this.showMessageBox_(this.activeWindow(), { type: 'question', message: message, buttons: [_('OK'), _('Cancel')], ...options }); @@ -369,7 +386,7 @@ export class Bridge { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public showInfoMessageBox(message: string, options: any = {}) { - const result = this.showMessageBox_(this.window(), { type: 'info', + const result = this.showMessageBox_(this.activeWindow(), { type: 'info', message: message, buttons: [_('OK')], ...options }); return result === 0; @@ -413,7 +430,7 @@ export class Bridge { const allowOpenId = 2; const learnMoreId = 1; const fileExtensionDescription = JSON.stringify(fileExtension); - const result = await dialog.showMessageBox(this.window(), { + const result = await dialog.showMessageBox(this.activeWindow(), { title: _('Unknown file type'), message: _('Joplin doesn\'t recognise the %s extension. Opening this file could be dangerous. What would you like to do?', fileExtensionDescription), diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 431ef377f3a..a6a7479a90a 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -5,6 +5,7 @@ import * as emptyTrash from './emptyTrash'; import * as exportFolders from './exportFolders'; import * as exportNotes from './exportNotes'; import * as focusElement from './focusElement'; +import * as openNoteInNewWindow from './openNoteInNewWindow'; import * as openProfileDirectory from './openProfileDirectory'; import * as renderMarkup from './renderMarkup'; import * as replaceMisspelling from './replaceMisspelling'; @@ -25,6 +26,7 @@ const index: any[] = [ exportFolders, exportNotes, focusElement, + openNoteInNewWindow, openProfileDirectory, renderMarkup, replaceMisspelling, diff --git a/packages/app-desktop/commands/openNoteInNewWindow.ts b/packages/app-desktop/commands/openNoteInNewWindow.ts new file mode 100644 index 00000000000..7683e635d76 --- /dev/null +++ b/packages/app-desktop/commands/openNoteInNewWindow.ts @@ -0,0 +1,36 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import { stateUtils } from '@joplin/lib/reducer'; +import Note from '@joplin/lib/models/Note'; +import { createAppDefaultWindowState } from '../app.reducer'; +import Setting from '@joplin/lib/models/Setting'; + +export const declaration: CommandDeclaration = { + name: 'openNoteInNewWindow', + label: () => _('Edit in new window'), + iconName: 'icon-share', +}; + +let idCounter = 0; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, noteId: string = null) => { + noteId = noteId || stateUtils.selectedNoteId(context.state); + + const note = await Note.load(noteId, { fields: ['parent_id'] }); + context.dispatch({ + type: 'WINDOW_OPEN', + noteId, + folderId: note.parent_id, + windowId: `window-${noteId}-${idCounter++}`, + defaultAppWindowState: { + ...createAppDefaultWindowState(), + noteVisiblePanes: Setting.value('noteVisiblePanes'), + editorCodeView: Setting.value('editor.codeView'), + }, + }); + }, + enabledCondition: 'oneNoteSelected', + }; +}; diff --git a/packages/app-desktop/commands/replaceMisspelling.ts b/packages/app-desktop/commands/replaceMisspelling.ts index a1e0c7e436b..4f37765df79 100644 --- a/packages/app-desktop/commands/replaceMisspelling.ts +++ b/packages/app-desktop/commands/replaceMisspelling.ts @@ -22,7 +22,7 @@ export const runtime = (): CommandRuntime => { if (!modalDialogVisible && (isInsideContainer(activeElement, 'codeMirrorEditor') || isInsideContainer(activeElement, 'tox-edit-area__iframe'))) { await CommandService.instance().execute('replaceSelection', suggestion); } else { - bridge().window().webContents.replaceMisspelling(suggestion); + bridge().activeWindow().webContents.replaceMisspelling(suggestion); } }, }; diff --git a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx index 6e516597688..e4ecf74cae4 100644 --- a/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx +++ b/packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.tsx @@ -229,7 +229,7 @@ export default function(props: Props) { ]; const menu = bridge().Menu.buildFromTemplate(template); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().mainWindow() }); }, [onInstall, onBrowsePlugins]); const onSearchQueryChange = useCallback((event: OnChangeEvent) => { diff --git a/packages/app-desktop/gui/Dialog.tsx b/packages/app-desktop/gui/Dialog.tsx index 20217f91f87..ce21f10f6b5 100644 --- a/packages/app-desktop/gui/Dialog.tsx +++ b/packages/app-desktop/gui/Dialog.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ReactNode, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { blur, focus } from '@joplin/lib/utils/focusHandler'; +import useDocument from './hooks/useDocument'; type OnCancelListener = ()=> void; @@ -9,10 +10,14 @@ interface Props { className?: string; onCancel?: OnCancelListener; contentStyle?: React.CSSProperties; + contentFillsScreen?: boolean; children: ReactNode; } const Dialog: React.FC = props => { + const [containerElement, setContainerElement] = useState(null); + const containerDocument = useDocument(containerElement); + // For correct focus handling, the dialog element needs to be managed separately from React. In particular, // just after creating the dialog, we need to call .showModal() and just **before** closing the dialog, we // need to call .close(). This second requirement is particularly difficult, as this needs to happen even @@ -21,7 +26,7 @@ const Dialog: React.FC = props => { // Because useEffect cleanup can happen after an element is removed from the HTML DOM, the dialog is managed // using native HTML APIs. This allows us to call .close() while the dialog is still attached to the DOM, which // allows the browser to restore the focus from before the dialog was opened. - const dialogElement = useDialogElement(props.onCancel); + const dialogElement = useDialogElement(containerDocument, props.onCancel); useDialogClassNames(dialogElement, props.className); const [contentRendered, setContentRendered] = useState(false); @@ -34,6 +39,16 @@ const Dialog: React.FC = props => { } }, [dialogElement, contentRendered]); + useEffect(() => { + if (!dialogElement) return; + + if (props.contentFillsScreen) { + dialogElement.classList.add('-fullscreen'); + } else { + dialogElement.classList.remove('-fullscreen'); + } + }, [props.contentFillsScreen, dialogElement]); + if (dialogElement && !contentRendered) { setContentRendered(true); } @@ -43,19 +58,21 @@ const Dialog: React.FC = props => { {props.children} ); - return <> - {dialogElement && createPortal(content, dialogElement)} - ; + return
+ {dialogElement && createPortal(content, dialogElement) as ReactNode} +
; }; -const useDialogElement = (onCancel: undefined|OnCancelListener) => { +const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener) => { const [dialogElement, setDialogElement] = useState(null); const onCancelRef = useRef(onCancel); onCancelRef.current = onCancel; useEffect(() => { - const dialog = document.createElement('dialog'); + if (!containerDocument) return () => {}; + + const dialog = containerDocument.createElement('dialog'); dialog.addEventListener('click', event => { const onCancel = onCancelRef.current; const isBackgroundClick = event.target === dialog; @@ -84,13 +101,13 @@ const useDialogElement = (onCancel: undefined|OnCancelListener) => { // Work around what seems to be an Electron bug -- if an input or contenteditable region is refocused after // dismissing a dialog, it won't be editable. // Note: While this addresses the issue in the note title input, it does not address the issue in the Rich Text Editor. - if (document.activeElement?.tagName === 'INPUT') { - const element = document.activeElement as HTMLElement; + if (containerDocument.activeElement?.tagName === 'INPUT') { + const element = containerDocument.activeElement as HTMLElement; blur('Dialog', element); focus('Dialog', element); } }); - document.body.appendChild(dialog); + containerDocument.body.appendChild(dialog); setDialogElement(dialog); @@ -102,7 +119,7 @@ const useDialogElement = (onCancel: undefined|OnCancelListener) => { } dialog.remove(); }; - }, []); + }, [containerDocument]); return dialogElement; }; diff --git a/packages/app-desktop/gui/EditFolderDialog/IconSelector.tsx b/packages/app-desktop/gui/EditFolderDialog/IconSelector.tsx index 69f3089d238..04fc758842b 100644 --- a/packages/app-desktop/gui/EditFolderDialog/IconSelector.tsx +++ b/packages/app-desktop/gui/EditFolderDialog/IconSelector.tsx @@ -35,7 +35,7 @@ export const IconSelector = (props: Props) => { attrs: { type: 'module', }, - }); + }, document); if (event.cancelled) return; @@ -45,7 +45,7 @@ export const IconSelector = (props: Props) => { attrs: { type: 'module', }, - }); + }, document); if (event.cancelled) return; diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen.tsx similarity index 72% rename from packages/app-desktop/gui/MainScreen/MainScreen.tsx rename to packages/app-desktop/gui/MainScreen.tsx index 521d93f63ea..f762a28ba96 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen.tsx @@ -1,63 +1,49 @@ import * as React from 'react'; -import ResizableLayout from '../ResizableLayout/ResizableLayout'; -import findItemByKey from '../ResizableLayout/utils/findItemByKey'; -import { MoveButtonClickEvent } from '../ResizableLayout/MoveButtons'; -import { move } from '../ResizableLayout/utils/movements'; -import { LayoutItem } from '../ResizableLayout/utils/types'; -import NoteEditor from '../NoteEditor/NoteEditor'; -import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog'; -import ShareNoteDialog from '../ShareNoteDialog'; +import ResizableLayout from './ResizableLayout/ResizableLayout'; +import findItemByKey from './ResizableLayout/utils/findItemByKey'; +import { MoveButtonClickEvent } from './ResizableLayout/MoveButtons'; +import { move } from './ResizableLayout/utils/movements'; +import { LayoutItem } from './ResizableLayout/utils/types'; import CommandService from '@joplin/lib/services/CommandService'; import { PluginHtmlContents, PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; -import Sidebar from '../Sidebar/Sidebar'; -import UserWebview from '../../services/plugins/UserWebview'; -import UserWebviewDialog from '../../services/plugins/UserWebviewDialog'; +import Sidebar from './Sidebar/Sidebar'; +import UserWebview from '../services/plugins/UserWebview'; +import UserWebviewDialog from '../services/plugins/UserWebviewDialog'; import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; -import { StateLastDeletion, stateUtils } from '@joplin/lib/reducer'; -import InteropServiceHelper from '../../InteropServiceHelper'; +import { defaultWindowId, StateLastDeletion, stateUtils } from '@joplin/lib/reducer'; import { _ } from '@joplin/lib/locale'; -import NoteListWrapper from '../NoteListWrapper/NoteListWrapper'; -import { AppState } from '../../app.reducer'; -import { saveLayout, loadLayout } from '../ResizableLayout/utils/persist'; +import NoteListWrapper from './NoteListWrapper/NoteListWrapper'; +import { AppState } from '../app.reducer'; +import { saveLayout, loadLayout } from './ResizableLayout/utils/persist'; import Setting from '@joplin/lib/models/Setting'; import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning'; import produce from 'immer'; import shim from '@joplin/lib/shim'; -import bridge from '../../services/bridge'; -import time from '@joplin/lib/time'; +import bridge from '../services/bridge'; import styled from 'styled-components'; import { themeStyle, ThemeStyle } from '@joplin/lib/theme'; -import validateLayout from '../ResizableLayout/utils/validateLayout'; -import iterateItems from '../ResizableLayout/utils/iterateItems'; -import removeItem from '../ResizableLayout/utils/removeItem'; +import validateLayout from './ResizableLayout/utils/validateLayout'; +import iterateItems from './ResizableLayout/utils/iterateItems'; +import removeItem from './ResizableLayout/utils/removeItem'; import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService'; -import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog'; import { ShareInvitation } from '@joplin/lib/services/share/reducer'; -import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems'; +import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems'; import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils'; import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils'; -import ElectronAppWrapper from '../../ElectronAppWrapper'; +import ElectronAppWrapper from '../ElectronAppWrapper'; import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils'; import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types'; -import commands from './commands/index'; import invitationRespond from '@joplin/lib/services/share/invitationRespond'; -import restart from '../../services/restart'; -const { connect } = require('react-redux'); -import PromptDialog from '../PromptDialog'; -import NotePropertiesDialog from '../NotePropertiesDialog'; +import restart from '../services/restart'; +import { connect } from 'react-redux'; import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; -import validateColumns from '../NoteListHeader/utils/validateColumns'; -import TrashNotification from '../TrashNotification/TrashNotification'; -import UpdateNotification from '../UpdateNotification/UpdateNotification'; +import validateColumns from './NoteListHeader/utils/validateColumns'; +import TrashNotification from './TrashNotification/TrashNotification'; +import UpdateNotification from './UpdateNotification/UpdateNotification'; +import NoteEditor from './NoteEditor/NoteEditor'; -const PluginManager = require('@joplin/lib/services/PluginManager'); const ipcRenderer = require('electron').ipcRenderer; -interface LayerModalState { - visible: boolean; - message: string; -} - interface Props { plugins: PluginStates; pluginHtmlContents: PluginHtmlContents; @@ -69,9 +55,6 @@ interface Props { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied style: any; layoutMoveMode: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - editorNoteStatuses: any; - customCss: string; shouldUpgradeSyncTarget: boolean; hasDisabledSyncItems: boolean; hasDisabledEncryptionItems: boolean; @@ -80,9 +63,6 @@ interface Props { showNeedUpgradingMasterKeyMessage: boolean; showShouldReencryptMessage: boolean; themeId: number; - settingEditorCodeView: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - pluginsLegacy: any; startupPluginsLoaded: boolean; shareInvitations: ShareInvitation[]; isSafeMode: boolean; @@ -109,7 +89,6 @@ interface ShareFolderDialogOptions { interface State { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied promptOptions: any; - modalLayer: LayerModalState; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied notePropertiesDialogOptions: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -143,22 +122,15 @@ class MainScreenComponent extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private waitForNotesSavedIID_: any; - private isPrinting_: boolean; private styleKey_: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private styles_: any; - // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied - private promptOnClose_: Function; public constructor(props: Props) { super(props); this.state = { promptOptions: null, - modalLayer: { - visible: false, - message: '', - }, notePropertiesDialogOptions: {}, noteContentPropertiesDialogOptions: {}, shareNoteDialogOptions: {}, @@ -170,14 +142,8 @@ class MainScreenComponent extends React.Component { this.updateMainLayout(this.buildLayout(props.plugins)); - this.registerCommands(); - this.setupAppCloseHandling(); - 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.shareFolderDialog_close = this.shareFolderDialog_close.bind(this); this.resizableLayout_resize = this.resizableLayout_resize.bind(this); this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this); this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this); @@ -318,22 +284,6 @@ class MainScreenComponent extends React.Component { }); } - private notePropertiesDialog_close() { - this.setState({ notePropertiesDialogOptions: {} }); - } - - private noteContentPropertiesDialog_close() { - this.setState({ noteContentPropertiesDialogOptions: {} }); - } - - private shareNoteDialog_close() { - this.setState({ shareNoteDialogOptions: {} }); - } - - private shareFolderDialog_close() { - this.setState({ shareFolderDialogOptions: { visible: false, folderId: '' } }); - } - public updateMainLayout(layout: LayoutItem) { this.props.dispatch({ type: 'MAIN_LAYOUT_SET', @@ -363,34 +313,6 @@ class MainScreenComponent extends React.Component { // this.setState({ layout: this.buildLayout(this.props.plugins) }); } - if (this.state.notePropertiesDialogOptions !== prevState.notePropertiesDialogOptions) { - this.props.dispatch({ - type: this.state.notePropertiesDialogOptions && this.state.notePropertiesDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', - name: 'noteProperties', - }); - } - - if (this.state.noteContentPropertiesDialogOptions !== prevState.noteContentPropertiesDialogOptions) { - this.props.dispatch({ - type: this.state.noteContentPropertiesDialogOptions && this.state.noteContentPropertiesDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', - name: 'noteContentProperties', - }); - } - - if (this.state.shareNoteDialogOptions !== prevState.shareNoteDialogOptions) { - this.props.dispatch({ - type: this.state.shareNoteDialogOptions && this.state.shareNoteDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', - name: 'shareNote', - }); - } - - if (this.state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) { - this.props.dispatch({ - type: this.state.shareFolderDialogOptions && this.state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', - name: 'shareFolder', - }); - } - if (this.props.mainLayout !== prevProps.mainLayout) { const toSave = saveLayout(this.props.mainLayout); Setting.setValue('ui.layout', toSave); @@ -425,62 +347,10 @@ class MainScreenComponent extends React.Component { } public componentWillUnmount() { - this.unregisterCommands(); - window.removeEventListener('resize', this.window_resize); window.removeEventListener('keydown', this.layoutModeListenerKeyDown); } - public async waitForNoteToSaved(noteId: string) { - while (noteId && this.props.editorNoteStatuses[noteId] === 'saving') { - // eslint-disable-next-line no-console - console.info('Waiting for note to be saved...', this.props.editorNoteStatuses); - await time.msleep(100); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public async printTo_(target: string, options: any) { - // Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion - if (this.isPrinting_) { - // eslint-disable-next-line no-console - console.info(`Printing ${options.path} to ${target} disallowed, already printing.`); - return; - } - - this.isPrinting_ = true; - - // Need to wait for save because the interop service reloads the note from the database - await this.waitForNoteToSaved(options.noteId); - - if (target === 'pdf') { - try { - const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, { - printBackground: true, - pageSize: Setting.value('export.pdfPageSize'), - landscape: Setting.value('export.pdfPageOrientation') === 'landscape', - customCss: this.props.customCss, - plugins: this.props.plugins, - }); - await shim.fsDriver().writeFile(options.path, pdfData, 'buffer'); - } catch (error) { - console.error(error); - bridge().showErrorMessageBox(error.message); - } - } else if (target === 'printer') { - try { - await InteropServiceHelper.printNote(options.noteId, { - printBackground: true, - customCss: this.props.customCss, - }); - } catch (error) { - console.error(error); - bridge().showErrorMessageBox(error.message); - } - } - this.isPrinting_ = false; - } - public rootLayoutSize() { return { width: window.innerWidth, @@ -533,15 +403,6 @@ class MainScreenComponent extends React.Component { height: height, }; - this.styles_.modalLayer = { ...theme.textStyle, zIndex: 10000, - position: 'absolute', - top: 0, - left: 0, - backgroundColor: theme.backgroundColor, - width: width - 20, - height: height - 20, - padding: 10 }; - return this.styles_; } @@ -724,18 +585,6 @@ class MainScreenComponent extends React.Component { props.showInvalidJoplinCloudCredential; } - public registerCommands() { - for (const command of commands) { - CommandService.instance().registerRuntime(command.declaration.name, command.runtime(this)); - } - } - - public unregisterCommands() { - for (const command of commands) { - CommandService.instance().unregisterRuntime(command.declaration.name); - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private resizableLayout_resize(event: any) { this.updateMainLayout(event.layout); @@ -784,14 +633,10 @@ class MainScreenComponent extends React.Component { }, editor: () => { - let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror6' : 'TinyMCE'; - - if (this.props.isSafeMode) { - bodyEditor = 'PlainText'; - } else if (this.props.settingEditorCodeView && this.props.enableLegacyMarkdownEditor) { - bodyEditor = 'CodeMirror5'; - } - return ; + return ; }, }; @@ -884,28 +729,10 @@ class MainScreenComponent extends React.Component { backgroundColor: theme.backgroundColor, ...this.props.style, }; - const promptOptions = this.state.promptOptions; const styles = this.styles(this.props.themeId, style.width, style.height, this.messageBoxVisible()); - if (!this.promptOnClose_) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - this.promptOnClose_ = (answer: any, buttonType: any) => { - return this.state.promptOptions.onClose(answer, buttonType); - }; - } - const messageComp = this.renderNotification(theme, styles); - const dialogInfo = PluginManager.instance().pluginDialogToShow(this.props.pluginsLegacy); - const pluginDialog = !dialogInfo ? null : ; - - const modalLayerStyle = { ...styles.modalLayer, display: this.state.modalLayer.visible ? 'block' : 'none' }; - - const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; - const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions; - const shareNoteDialogOptions = this.state.shareNoteDialogOptions; - const shareFolderDialogOptions = this.state.shareFolderDialogOptions; - const layoutComp = this.props.mainLayout ? ( { return (
-
{this.state.modalLayer.message}
- {this.renderPluginDialogs()} - {noteContentPropertiesDialogOptions.visible && } - {notePropertiesDialogOptions.visible && } - {shareNoteDialogOptions.visible && } - {shareFolderDialogOptions.visible && } - - - { {messageComp} {layoutComp} - {pluginDialog}
); } @@ -948,10 +765,10 @@ class MainScreenComponent extends React.Component { const mapStateToProps = (state: AppState) => { const syncInfo = localSyncInfoFromState(state); const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length; + const windowState = stateUtils.windowStateById(state, defaultWindowId); return { themeId: state.settings.theme, - settingEditorCodeView: state.settings['editor.codeView'], hasDisabledSyncItems: state.hasDisabledSyncItems, hasDisabledEncryptionItems: state.hasDisabledEncryptionItems, showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys), @@ -959,11 +776,8 @@ const mapStateToProps = (state: AppState) => { showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES, shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO, hasMissingSyncCredentials: shouldShowMissingPasswordWarning(state.settings['sync.target'], state.settings), - pluginsLegacy: state.pluginsLegacy, plugins: state.pluginService.plugins, pluginHtmlContents: state.pluginService.pluginHtmlContents, - customCss: state.customCss, - editorNoteStatuses: state.editorNoteStatuses, hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state), layoutMoveMode: state.layoutMoveMode, mainLayout: state.mainLayout, @@ -977,7 +791,7 @@ const mapStateToProps = (state: AppState) => { listRendererId: state.settings['notes.listRendererId'], lastDeletion: state.lastDeletion, lastDeletionNotificationTime: state.lastDeletionNotificationTime, - selectedFolderId: state.selectedFolderId, + selectedFolderId: windowState.selectedFolderId, mustUpgradeAppMessage: state.mustUpgradeAppMessage, notesSortOrderField: state.settings['notes.sortOrder.field'], notesSortOrderReverse: state.settings['notes.sortOrder.reverse'], diff --git a/packages/app-desktop/gui/MainScreen/commands/hideModalMessage.ts b/packages/app-desktop/gui/MainScreen/commands/hideModalMessage.ts deleted file mode 100644 index 1efcc7442d8..00000000000 --- a/packages/app-desktop/gui/MainScreen/commands/hideModalMessage.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; - -export const declaration: CommandDeclaration = { - name: 'hideModalMessage', -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export const runtime = (comp: any): CommandRuntime => { - return { - execute: async () => { - comp.setState({ modalLayer: { visible: false, message: '' } }); - }, - }; -}; diff --git a/packages/app-desktop/gui/MainScreen/commands/showModalMessage.tsx b/packages/app-desktop/gui/MainScreen/commands/showModalMessage.tsx deleted file mode 100644 index f5414cf3bc5..00000000000 --- a/packages/app-desktop/gui/MainScreen/commands/showModalMessage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService'; - -export const declaration: CommandDeclaration = { - name: 'showModalMessage', -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export const runtime = (comp: any): CommandRuntime => { - return { - execute: async (_context: CommandContext, message: string) => { - let brIndex = 1; - const lines = message.split('\n').map((line: string) => { - if (!line.trim()) return
; - return
{line}
; - }); - - comp.setState({ - modalLayer: { - visible: true, - message: -
-
-
- {lines} -
-
, - }, - }); - }, - }; -}; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 5f39fc90430..2b3e031e7fa 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { AppState } from '../app.reducer'; import InteropService from '@joplin/lib/services/interop/InteropService'; -import { stateUtils } from '@joplin/lib/reducer'; +import { defaultWindowId, stateUtils } from '@joplin/lib/reducer'; import CommandService from '@joplin/lib/services/CommandService'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import KeymapService from '@joplin/lib/services/KeymapService'; @@ -19,7 +19,7 @@ import menuCommandNames from './menuCommandNames'; import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext'; import bridge from '../services/bridge'; import checkForUpdates from '../checkForUpdates'; -const { connect } = require('react-redux'); +import { connect } from 'react-redux'; import { reg } from '@joplin/lib/registry'; import { ProfileConfig } from '@joplin/lib/services/profileConfig/types'; import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; @@ -27,6 +27,11 @@ import { getListRendererById, getListRendererIds } from '@joplin/lib/services/no import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import { EventName } from '@joplin/lib/eventManager'; import { ipcRenderer } from 'electron'; +import NavService from '@joplin/lib/services/NavService'; +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('MenuBar'); + const packageInfo: PackageInfo = require('../packageInfo.js'); const { clipboard } = require('electron'); const Menu = bridge().Menu; @@ -150,7 +155,7 @@ interface Props { dispatch: Function; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied menuItemProps: any; - routeName: string; + mainScreenVisible: boolean; selectedFolderId: string; layoutButtonSequence: number; ['notes.sortOrder.field']: string; @@ -173,6 +178,8 @@ interface Props { pluginSettings: PluginSettings; noteListRendererIds: string[]; noteListRendererId: string; + windowId: string; + secondaryWindowFocused: boolean; showMenuBar: boolean; } @@ -192,11 +199,11 @@ function menuItemSetEnabled(id: string, enabled: boolean) { menuItem.enabled = enabled; } -const applyMenuBarVisibility = (showMenuBar: boolean) => { +const applyMenuBarVisibility = (windowId: string, showMenuBar: boolean) => { // The menu bar cannot be hidden on macOS if (shim.isMac()) return; - const window = bridge().window(); + const window = bridge().windowById(windowId) ?? bridge().mainWindow(); window.setAutoHideMenuBar(!showMenuBar); window.setMenuBarVisibility(showMenuBar); }; @@ -402,6 +409,17 @@ function useMenu(props: Props) { const keymapService = KeymapService.instance(); + const navigateTo = (routeName: string) => { + void NavService.go(routeName); + + // NavService.go opens in the main window -- switch to it to show the screen: + const isBackgroundWindow = props.windowId !== defaultWindowId; + if (isBackgroundWindow) { + logger.info('Focusing the main window'); + bridge().mainWindow().show(); + } + }; + const quitMenuItem = { label: _('Quit'), accelerator: keymapService.getAccelerator('quit'), @@ -515,10 +533,7 @@ function useMenu(props: Props) { const syncStatusItem = { label: _('Synchronisation Status'), click: () => { - props.dispatch({ - type: 'NAV_GO', - routeName: 'Status', - }); + navigateTo('Status'); }, }; @@ -548,10 +563,7 @@ function useMenu(props: Props) { label: _('Options'), accelerator: keymapService.getAccelerator('config'), click: () => { - props.dispatch({ - type: 'NAV_GO', - routeName: 'Config', - }); + navigateTo('Config'); }, }, separator(), @@ -561,10 +573,7 @@ function useMenu(props: Props) { const toolsItemsAll = [{ label: _('Note attachments...'), click: () => { - props.dispatch({ - type: 'NAV_GO', - routeName: 'Resources', - }); + navigateTo('Resources'); }, }]; @@ -579,7 +588,7 @@ function useMenu(props: Props) { if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) { ipcRenderer.send('check-for-updates'); } else { - void checkForUpdates(false, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); + void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); } } @@ -619,10 +628,7 @@ function useMenu(props: Props) { visible: !!shim.isMac(), accelerator: shim.isMac() && keymapService.getAccelerator('config'), click: () => { - props.dispatch({ - type: 'NAV_GO', - routeName: 'Config', - }); + navigateTo('Config'); }, }, { label: _('Check for updates...'), @@ -1019,7 +1025,7 @@ function useMenu(props: Props) { rootMenus.help, ].filter(item => item !== null); - if (props.routeName !== 'Main') { + if (!props.mainScreenVisible) { setMenu(Menu.buildFromTemplate([ { label: _('&File'), @@ -1049,7 +1055,8 @@ function useMenu(props: Props) { }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [ - props.routeName, + props.windowId, + props.mainScreenVisible, props.pluginMenuItems, props.pluginMenus, keymapLastChangeTime, @@ -1099,18 +1106,36 @@ function useMenu(props: Props) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied function MenuBar(props: Props): any { const menu = useMenu(props); - if (menu) Menu.setApplicationMenu(menu); - applyMenuBarVisibility(props.showMenuBar); + + useEffect(() => { + // Currently, this sets the menu for all windows. Although it's possible to set the menu + // for individual windows with BrowserWindow.setMenu, it causes issues with updating the + // state of existing menu items (and doesn't work with MacOS/Playwright). + if (menu) { + Menu.setApplicationMenu(menu); + } + }, [menu]); + + useEffect(() => { + applyMenuBarVisibility(props.windowId, props.showMenuBar); + }, [props.showMenuBar, props.windowId]); + return null; } -const mapStateToProps = (state: AppState) => { + +const mapStateToProps = (state: AppState): Partial => { const whenClauseContext = stateToWhenClauseContext(state); + const secondaryWindowFocused = state.windowId !== defaultWindowId; + return { + windowId: state.windowId, menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(getPluginCommandNames(state.pluginService.plugins)), whenClauseContext), locale: state.settings.locale, - routeName: state.route.routeName, + // Secondary windows can only show the main screen + mainScreenVisible: state.route.routeName === 'Main' || secondaryWindowFocused, + selectedFolderId: state.selectedFolderId, layoutButtonSequence: state.settings.layoutButtonSequence, ['notes.sortOrder.field']: state.settings['notes.sortOrder.field'], @@ -1126,7 +1151,7 @@ const mapStateToProps = (state: AppState) => { ['spellChecker.languages']: state.settings['spellChecker.languages'], ['spellChecker.enabled']: state.settings['spellChecker.enabled'], plugins: state.pluginService.plugins, - customCss: state.customCss, + customCss: state.customViewerCss, profileConfig: state.profileConfig, noteListRendererIds: state.noteListRendererIds, noteListRendererId: state.settings['notes.listRendererId'], diff --git a/packages/app-desktop/gui/MultiNoteActions.tsx b/packages/app-desktop/gui/MultiNoteActions.tsx index c97bdf0f308..cd426f7518d 100644 --- a/packages/app-desktop/gui/MultiNoteActions.tsx +++ b/packages/app-desktop/gui/MultiNoteActions.tsx @@ -5,7 +5,7 @@ import { Dispatch } from 'redux'; import { ThemeStyle } from '@joplin/lib/theme'; import { buildStyle } from '@joplin/lib/theme'; -const bridge = require('@electron/remote').require('./bridge').default; +import bridge from '../services/bridge'; interface MultiNoteActionsProps { themeId: number; @@ -46,7 +46,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const multiNotesButton_click = (item: any) => { if (item.submenu) { - item.submenu.popup({ window: bridge().window() }); + item.submenu.popup({ window: bridge().activeWindow() }); } else { item.click(); } diff --git a/packages/app-desktop/gui/Navigator.tsx b/packages/app-desktop/gui/Navigator.tsx index fcf6cc3eab8..ae04510e2e7 100644 --- a/packages/app-desktop/gui/Navigator.tsx +++ b/packages/app-desktop/gui/Navigator.tsx @@ -1,55 +1,67 @@ -const React = require('react'); +import * as React from 'react'; const { connect } = require('react-redux'); import Setting from '@joplin/lib/models/Setting'; -import { AppState } from '../app.reducer'; -const bridge = require('@electron/remote').require('./bridge').default; +import { AppState, AppStateRoute } from '../app.reducer'; +import bridge from '../services/bridge'; +import { useContext, useEffect, useRef } from 'react'; +import { WindowIdContext } from './NewWindowOrIFrame'; interface Props { + route: AppStateRoute; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - route: any; + screens: Record; + + style: React.CSSProperties; + className?: string; } -class NavigatorComponent extends React.Component { - public UNSAFE_componentWillReceiveProps(newProps: Props) { - if (newProps.route) { - const screenInfo = this.props.screens[newProps.route.routeName]; +const NavigatorComponent: React.FC = props => { + const windowId = useContext(WindowIdContext); + + const route = props.route; + const screenInfo = props.screens[route?.routeName]; + + const screensRef = useRef(props.screens); + screensRef.current = props.screens; + + const prevRoute = useRef(null); + useEffect(() => { + const routeName = route?.routeName; + if (route) { const devMarker = Setting.value('env') === 'dev' ? ` (DEV - ${Setting.value('profileDir')})` : ''; const windowTitle = [`Joplin${devMarker}`]; if (screenInfo.title) { windowTitle.push(screenInfo.title()); } - this.updateWindowTitle(windowTitle.join(' - ')); + bridge().windowById(windowId)?.setTitle(windowTitle.join(' - ')); } - } - public updateWindowTitle(title: string) { - try { - if (bridge().window()) bridge().window().setTitle(title); - } catch (error) { - console.warn('updateWindowTitle', error); + // When a navigation happens in an unfocused window, show the window to the user. + // This might happen if, for example, a secondary window triggers a navigation in + // the main window. + if (routeName && routeName !== prevRoute.current?.routeName) { + bridge().switchToWindow(windowId); } - } - - public render() { - if (!this.props.route) throw new Error('Route must not be null'); - - const route = this.props.route; - const screenProps = route.props ? route.props : {}; - const screenInfo = this.props.screens[route.routeName]; - const Screen = screenInfo.screen; - - const screenStyle = { - width: this.props.style.width, - height: this.props.style.height, - }; - - return ( -
- -
- ); - } -} + + prevRoute.current = route; + }, [route, screenInfo, windowId]); + + if (!route) throw new Error('Route must not be null'); + + const screenProps = route.props ? route.props : {}; + const Screen = screenInfo.screen; + + const screenStyle = { + width: props.style.width, + height: props.style.height, + }; + + return ( +
+ +
+ ); +}; const Navigator = connect((state: AppState) => { return { diff --git a/packages/app-desktop/gui/NewWindowOrIFrame.tsx b/packages/app-desktop/gui/NewWindowOrIFrame.tsx new file mode 100644 index 00000000000..11d5d9e698b --- /dev/null +++ b/packages/app-desktop/gui/NewWindowOrIFrame.tsx @@ -0,0 +1,172 @@ +import { defaultWindowId } from '@joplin/lib/reducer'; +import shim from '@joplin/lib/shim'; +import * as React from 'react'; +import { useState, useEffect, useRef, createContext } from 'react'; +import { createPortal } from 'react-dom'; +import { SecondaryWindowApi } from '../utils/window/types'; + +// This component uses react-dom's Portals to render its children in a different HTML +// document. As children are rendered in a different Window/Document, they should avoid +// referencing the `window` and `document` globals. Instead, HTMLElement.ownerDocument +// and refs can be used to access the child component's DOM. + +export const WindowIdContext = createContext(defaultWindowId); + +type OnCloseCallback = ()=> void; +type OnFocusCallback = ()=> void; + +export enum WindowMode { + Iframe, NewWindow, +} + +interface Props { + // Note: children will be rendered in a different DOM from this node. Avoid using document.* methods + // in child components. + children: React.ReactNode[]|React.ReactNode; + title: string; + mode: WindowMode; + windowId: string; + onClose: OnCloseCallback; + onFocus?: OnFocusCallback; +} + +const useDocument = ( + mode: WindowMode, + iframeElement: HTMLIFrameElement|null, + onClose: OnCloseCallback, +) => { + const [doc, setDoc] = useState(null); + + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + let openedWindow: Window|null = null; + const unmounted = false; + if (iframeElement) { + setDoc(iframeElement?.contentWindow?.document); + } else if (mode === WindowMode.NewWindow) { + openedWindow = window.open('about:blank'); + setDoc(openedWindow.document); + + // .onbeforeunload and .onclose events don't seem to fire when closed by a user -- rely on polling + // instead: + void (async () => { + while (!unmounted) { + await new Promise(resolve => { + shim.setTimeout(() => resolve(), 2000); + }); + + if (openedWindow?.closed) { + onCloseRef.current?.(); + openedWindow = null; + break; + } + } + })(); + } + + return () => { + // Delay: Closing immediately causes Electron to crash + setTimeout(() => { + if (!openedWindow?.closed) { + openedWindow?.close(); + onCloseRef.current?.(); + openedWindow = null; + } + }, 200); + + if (iframeElement && !openedWindow) { + onCloseRef.current?.(); + } + }; + }, [iframeElement, mode]); + + return doc; +}; + +type OnSetLoaded = (loaded: boolean)=> void; +const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded, onFocus?: OnFocusCallback) => { + const onFocusRef = useRef(onFocus); + onFocusRef.current = onFocus; + + useEffect(() => { + if (!doc) return; + + doc.open(); + doc.write(''); + doc.close(); + + const cssUrls = [ + 'style.min.css', + ]; + + for (const url of cssUrls) { + const style = doc.createElement('link'); + style.rel = 'stylesheet'; + style.href = url; + doc.head.appendChild(style); + } + + const jsUrls = [ + 'vendor/lib/smalltalk/dist/smalltalk.min.js', + './utils/window/eventHandlerOverrides.js', + ]; + for (const url of jsUrls) { + const script = doc.createElement('script'); + script.src = url; + doc.head.appendChild(script); + } + + doc.body.style.height = '100vh'; + + const containerWindow = doc.defaultView; + containerWindow.addEventListener('focus', () => { + onFocusRef.current?.(); + }); + if (doc.hasFocus()) { + onFocusRef.current?.(); + } + + setLoaded(true); + }, [doc, setLoaded]); +}; + +const NewWindowOrIFrame: React.FC = props => { + const [iframeRef, setIframeRef] = useState(null); + const [loaded, setLoaded] = useState(false); + + const doc = useDocument(props.mode, iframeRef, props.onClose); + useDocumentSetup(doc, setLoaded, props.onFocus); + + useEffect(() => { + if (!doc) return; + doc.title = props.title; + }, [doc, props.title]); + + useEffect(() => { + const win = doc?.defaultView; + if (win && 'electronWindow' in win && typeof win.electronWindow === 'object') { + const electronWindow = win.electronWindow as SecondaryWindowApi; + electronWindow.onSetWindowId(props.windowId); + } + }, [doc, props.windowId]); + + const parentNode = loaded ? doc?.body : null; + const wrappedChildren = {props.children}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to allow adding the portal to the DOM + const contentPortal = parentNode && createPortal(wrappedChildren, parentNode) as any; + if (props.mode === WindowMode.NewWindow) { + return
{contentPortal}
; + } else { + return ; + } +}; + +export default NewWindowOrIFrame; diff --git a/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx b/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx new file mode 100644 index 00000000000..4443b26fc0d --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import NoteEditor from './NoteEditor'; +import StyleSheetContainer from '../StyleSheets/StyleSheetContainer'; +import { connect } from 'react-redux'; +import { AppState } from '../../app.reducer'; +import { Dispatch } from 'redux'; +import NewWindowOrIFrame, { WindowMode } from '../NewWindowOrIFrame'; +import WindowCommandsAndDialogs from '../WindowCommandsAndDialogs/WindowCommandsAndDialogs'; + +const { StyleSheetManager } = require('styled-components'); +// Note: Transitive dependencies used only by react-select. Remove if react-select is removed. +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import { stateUtils } from '@joplin/lib/reducer'; + +interface Props { + dispatch: Dispatch; + themeId: number; + + newWindow: boolean; + windowId: string; + activeWindowId: string; +} + +const emptyCallback = () => {}; +const useWindowTitle = (isNewWindow: boolean) => { + const [title, setTitle] = useState('Untitled'); + + if (!isNewWindow) { + return { + windowTitle: 'Editor', + onNoteTitleChange: emptyCallback, + }; + } + + return { windowTitle: `Joplin - ${title}`, onNoteTitleChange: setTitle }; +}; + +const SecondaryWindow: React.FC = props => { + const containerRef = useRef(null); + + const { windowTitle, onNoteTitleChange } = useWindowTitle(props.newWindow); + const editor =
+ +
; + + const newWindow = props.newWindow; + const onWindowClose = useCallback(() => { + if (newWindow) { + props.dispatch({ type: 'WINDOW_CLOSE', windowId: props.windowId }); + } + }, [props.dispatch, props.windowId, newWindow]); + + const onWindowFocus = useCallback(() => { + // Verify that the window still has focus (e.g. to handle the case where the event was delayed). + if (containerRef.current?.ownerDocument.hasFocus()) { + props.dispatch({ + type: 'WINDOW_FOCUS', + windowId: props.windowId, + lastWindowId: props.activeWindowId, + }); + } + }, [props.dispatch, props.windowId, props.activeWindowId]); + + return + + + {editor} + + + ; +}; + +interface StyleProviderProps { + children: React.ReactNode[]|React.ReactNode; +} + +// Sets the root style container for libraries. At present, this is needed by react-select (which uses @emotion/...) +// and styled-components. +// See: https://github.com/JedWatson/react-select/issues/3680 and https://github.com/styled-components/styled-components/issues/659 +const LibraryStyleRoot: React.FC = props => { + const [dependencyStyleContainer, setDependencyStyleContainer] = useState(null); + const cache = useMemo(() => { + return createCache({ + key: 'new-window-cache', + container: dependencyStyleContainer, + }); + }, [dependencyStyleContainer]); + + return <> +
+ + + {props.children} + + + ; +}; + +interface ConnectProps { + windowId: string; +} + +export default connect((state: AppState, ownProps: ConnectProps) => { + // May be undefined if the window hasn't opened + const windowState = stateUtils.windowStateById(state, ownProps.windowId); + + return { + themeId: state.settings.theme, + isSafeMode: state.settings.isSafeMode, + codeView: windowState?.editorCodeView ?? state.settings['editor.codeView'], + legacyMarkdown: state.settings['editor.legacyMarkdown'], + activeWindowId: stateUtils.activeWindowId(state), + }; +})(SecondaryWindow); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx index 74361b6f7ca..cd5b286b741 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx @@ -40,8 +40,12 @@ function Toolbar(props: ToolbarProps) { ); } -const mapStateToProps = (state: AppState) => { - const whenClauseContext = stateToWhenClauseContext(state); +interface ConnectProps { + windowId: string; +} + +const mapStateToProps = (state: AppState, connectProps: ConnectProps) => { + const whenClauseContext = stateToWhenClauseContext(state, { windowId: connectProps.windowId }); const commandNames = [ 'historyBackward', diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts index ed6976260b5..67f0378c427 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts @@ -25,6 +25,7 @@ interface ContextMenuProps { editorPaste: ()=> void; editorRef: RefObject; editorClassName: string; + containerRef: RefObject; } const useContextMenu = (props: ContextMenuProps) => { @@ -51,12 +52,13 @@ const useContextMenu = (props: ContextMenuProps) => { function pointerInsideEditor(params: ContextMenuParams) { const x = params.x, y = params.y, isEditable = params.isEditable; - const elements = document.getElementsByClassName(props.editorClassName); + const containerDoc = props.containerRef.current?.ownerDocument; + const elements = containerDoc?.getElementsByClassName(props.editorClassName); // Note: We can't check inputFieldType here. When spellcheck is enabled, // params.inputFieldType is "none". When spellcheck is disabled, // params.inputFieldType is "plainText". Thus, such a check would be inconsistent. - if (!elements.length || !isEditable) return false; + if (!elements?.length || !isEditable) return false; // Checks whether the element the pointer clicked on is inside the editor. // This logic will need to be changed if the editor is eventually wrapped @@ -65,7 +67,7 @@ const useContextMenu = (props: ContextMenuProps) => { const zoom = Setting.value('windowContentZoomFactor'); const xScreen = convertFromScreenCoordinates(zoom, x); const yScreen = convertFromScreenCoordinates(zoom, y); - const intersectingElement = document.elementFromPoint(xScreen, yScreen); + const intersectingElement = containerDoc.elementFromPoint(xScreen, yScreen); return intersectingElement && isAncestorOfCodeMirrorEditor(intersectingElement); } @@ -150,18 +152,21 @@ const useContextMenu = (props: ContextMenuProps) => { menu.append(new MenuItem(item)); }); - menu.popup(); + menu.popup({ window: bridge().activeWindow() }); } // Prepend the event listener so that it gets called before // the listener that shows the default menu. - bridge().window().webContents.prependListener('context-menu', onContextMenu); + const targetWindow = bridge().activeWindow(); + targetWindow.webContents.prependListener('context-menu', onContextMenu); return () => { - bridge().window().webContents.off('context-menu', onContextMenu); + if (!targetWindow.isDestroyed()) { + targetWindow.webContents.off('context-menu', onContextMenu); + } }; }, [ - props.plugins, props.editorClassName, editorRef, + props.plugins, props.editorClassName, editorRef, props.containerRef, props.editorCutText, props.editorCopyText, props.editorPaste, ]); }; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx index 80acf4528c3..2cf8528cd22 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; +import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react'; // eslint-disable-next-line no-unused-vars import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types'; @@ -33,6 +33,7 @@ import useContextMenu from '../utils/useContextMenu'; import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; import useEditorSearchHandler from '../utils/useEditorSearchHandler'; import { focus } from '@joplin/lib/utils/focusHandler'; +import { WindowIdContext } from '../../../../NewWindowOrIFrame'; function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { return { ...override }; @@ -728,6 +729,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef
- + {props.noteToolbar}
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx index e09193c0d56..dc581aceecc 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; +import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react'; import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types'; import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; @@ -29,6 +29,7 @@ import Toolbar from '../Toolbar'; import useEditorSearchHandler from '../utils/useEditorSearchHandler'; import CommandService from '@joplin/lib/services/CommandService'; import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange'; +import { WindowIdContext } from '../../../../NewWindowOrIFrame'; const logger = Logger.create('CodeMirror6'); const logDebug = (message: string) => logger.debug(message); @@ -336,6 +337,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef(null); @@ -437,11 +439,13 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef
- + {props.noteToolbar}
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.ts index 1345bb5db44..466f683756e 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.ts @@ -1,18 +1,20 @@ import { RefObject, useRef, useEffect } from 'react'; import { focus } from '@joplin/lib/utils/focusHandler'; import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; -import NoteTextViewer from '../../../../../NoteTextViewer'; +import { NoteViewerControl } from '../../../../../NoteTextViewer'; interface Props { editorRef: RefObject; - webviewRef: RefObject; + webviewRef: RefObject; visiblePanes: string[]; } const useRefocusOnVisiblePaneChange = ({ editorRef, webviewRef, visiblePanes }: Props) => { const lastVisiblePanes = useRef(visiblePanes); useEffect(() => { - const editorHasFocus = editorRef.current?.cm6?.dom?.contains(document.activeElement); + const cm6Dom = editorRef.current?.cm6?.dom; + const doc = cm6Dom?.getRootNode() as Document|null; + const editorHasFocus = cm6Dom?.contains(doc?.activeElement); const viewerHasFocus = webviewRef.current?.hasFocus(); const lastHadViewer = lastVisiblePanes.current.includes('viewer'); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 92cce98e75e..cc4460fe19f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; +import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle, useMemo } from 'react'; import { ScrollOptions, ScrollOptionTypes, EditorCommand, NoteBodyEditorProps, ResourceInfos, HtmlToMarkdownHandler } from '../../utils/types'; import { resourcesStatus, commandAttachFileToBody, getResourcesFromPasteEvent, processPastedHtml } from '../../utils/resourceHandling'; import attachedResources from '@joplin/lib/utils/attachedResources'; @@ -41,6 +41,7 @@ const supportedLocales = require('./supportedLocales'); import { hasProtocol } from '@joplin/utils/url'; import useTabIndenter from './utils/useTabIndenter'; import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler'; +import useDocument from '../../../hooks/useDocument'; const logger = Logger.create('TinyMCE'); @@ -99,6 +100,8 @@ let changeId_ = 1; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { + const [editorContainer, setEditorContainer] = useState(null); + const editorContainerDom = useDocument(editorContainer); const [editor, setEditor] = useState(null); const [scriptLoaded, setScriptLoaded] = useState(false); const [editorReady, setEditorReady] = useState(false); @@ -119,9 +122,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { contentKey: null, }); - const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const editorRef = useRef(null); + const editorRef = useRef(null); editorRef.current = editor; const styles = styles_(props); @@ -333,6 +334,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // }; useEffect(() => { + if (!editorContainerDom) return () => {}; + let cancelled = false; async function loadScripts() { @@ -351,7 +354,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { ]; for (const s of scriptsToLoad) { - if (document.getElementById(s.id)) { + if (editorContainerDom.getElementById(s.id)) { s.loaded = true; continue; } @@ -359,7 +362,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // eslint-disable-next-line no-console console.info('Loading script', s.src); - await loadScript(s); + await loadScript(s, editorContainerDom); if (cancelled) return; s.loaded = true; @@ -373,19 +376,20 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { return () => { cancelled = true; }; - }, []); + }, [editorContainerDom]); - useWebViewApi(editor); + useWebViewApi(editor, editorContainerDom?.defaultView); const { resetModifiedTitles: resetLinkTooltips } = useLinkTooltips(editor); useEffect(() => { + if (!editorContainerDom) return () => {}; const theme = themeStyle(props.themeId); const backgroundColor = props.whiteBackgroundNoteRendering ? lightTheme.backgroundColor : theme.backgroundColor; - const element = document.createElement('style'); + const element = editorContainerDom.createElement('style'); element.setAttribute('id', 'tinyMceStyle'); - document.head.appendChild(element); - element.appendChild(document.createTextNode(` + editorContainerDom.head.appendChild(element); + element.appendChild(editorContainerDom.createTextNode(` .joplin-tinymce .tox-editor-header { padding-left: ${styles.leftExtraToolbarContainer.width + styles.leftExtraToolbarContainer.padding * 2}px; padding-right: ${styles.rightExtraToolbarContainer.width + styles.rightExtraToolbarContainer.padding * 2}px; @@ -582,7 +586,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { `)); return () => { - document.head.removeChild(element); + editorContainerDom.head.removeChild(element); }; // editorReady is here because TinyMCE starts by initializing a blank iframe, which needs to be // styled by us, otherwise users in dark mode get a bright white flash. During initialization @@ -594,7 +598,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // // tl;dr: editorReady is used here because the css needs to be re-applied after TinyMCE init // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [editorReady, props.themeId, lightTheme, props.whiteBackgroundNoteRendering, props.watchedNoteFiles]); + }, [editorReady, editorContainerDom, props.themeId, lightTheme, props.whiteBackgroundNoteRendering, props.watchedNoteFiles]); // ----------------------------------------------------------------------------------------- // Enable or disable the editor @@ -611,6 +615,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { useEffect(() => { if (!scriptLoaded) return; + if (!editorContainer) return; const loadEditor = async () => { const language = closestSupportedLocale(props.locale, true, supportedLocales); @@ -645,8 +650,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const editors = await (window as any).tinymce.init({ - selector: `#${rootIdRef.current}`, + const containerWindow = editorContainerDom.defaultView as any; + const editors = await containerWindow.tinymce.init({ + selector: `#${editorContainer.id}`, width: '100%', body_class: 'jop-tinymce', height: '100%', @@ -831,7 +837,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { void loadEditor(); // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [scriptLoaded]); + }, [scriptLoaded, editorContainer]); // ----------------------------------------------------------------------------------------- // Set the initial content and load the plugin CSS and JS files @@ -1421,12 +1427,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { ); } + const containerId = useMemo(() => { + return `tinymce-container-${Math.ceil(Math.random() * 1000)}-${Date.now()}`; + }, []); return (
{renderDisabledOverlay()} {renderLeftExtraToolbarButtons()} {renderRightExtraToolbarButtons()} -
+
); }; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts index 5ced08d1cce..b415f230ed6 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts @@ -8,21 +8,25 @@ import { menuItems } from '../../../utils/contextMenu'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import CommandService from '@joplin/lib/services/CommandService'; import Setting from '@joplin/lib/models/Setting'; +import type { Event as ElectronEvent } from 'electron'; import Resource from '@joplin/lib/models/Resource'; import { TinyMceEditorEvents } from './types'; import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from '../../../utils/types'; +import { Editor } from 'tinymce'; +const Menu = bridge().Menu; +const MenuItem = bridge().MenuItem; const menuUtils = new MenuUtils(CommandService.instance()); // x and y are the absolute coordinates, as returned by the context-menu event // handler on the webContent. This function will return null if the point is // not within the TinyMCE editor. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -function contextMenuElement(editor: any, x: number, y: number) { +function contextMenuElement(editor: Editor, x: number, y: number) { if (!editor || !editor.getDoc()) return null; - const iframes = document.getElementsByClassName('tox-edit-area__iframe'); + const containerDoc = editor.getContainer().ownerDocument; + const iframes = containerDoc.getElementsByClassName('tox-edit-area__iframe'); if (!iframes.length) return null; const zoom = Setting.value('windowContentZoomFactor') / 100; @@ -31,7 +35,7 @@ function contextMenuElement(editor: any, x: number, y: number) { // We use .elementFromPoint to handle the case where a dialog is covering // part of the editor. - const targetElement = document.elementFromPoint(xScreen, yScreen); + const targetElement = containerDoc.elementFromPoint(xScreen, yScreen); if (targetElement !== iframes[0]) { return null; } @@ -49,26 +53,31 @@ interface ContextMenuActionOptions { const contextMenuActionOptions: ContextMenuActionOptions = { current: null }; // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied -export default function(editor: any, plugins: PluginStates, dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) { +export default function(editor: Editor, plugins: PluginStates, dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) { useEffect(() => { if (!editor) return () => {}; const contextMenuItems = menuItems(dispatch, htmlToMd, mdToHtml); + const targetWindow = bridge().activeWindow(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - function onContextMenu(_event: any, params: any) { + function onContextMenu(event: ElectronEvent, params: any) { const element = contextMenuElement(editor, params.x, params.y); if (!element) return; + event.preventDefault(); + + const menu = new Menu(); + let itemType: ContextMenuItemType = ContextMenuItemType.None; let resourceId = ''; let linkToCopy = null; if (element.nodeName === 'IMG') { itemType = ContextMenuItemType.Image; - resourceId = Resource.pathToId(element.src); + resourceId = Resource.pathToId((element as HTMLImageElement).src); } else if (element.nodeName === 'A') { - resourceId = Resource.pathToId(element.href); + resourceId = Resource.pathToId((element as HTMLAnchorElement).href); itemType = resourceId ? ContextMenuItemType.Resource : ContextMenuItemType.Link; linkToCopy = element.getAttribute('href') || ''; } else { @@ -94,38 +103,37 @@ export default function(editor: any, plugins: PluginStates, dispatch: Function, mdToHtml, }; - let template = []; - for (const itemName in contextMenuItems) { const item = contextMenuItems[itemName]; if (!item.isActive(itemType, contextMenuActionOptions.current)) continue; - template.push({ + menu.append(new MenuItem({ label: item.label, click: () => { item.onAction(contextMenuActionOptions.current); }, - }); + })); } const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions); for (const item of spellCheckerMenuItems) { - template.push(item); + menu.append(new MenuItem(item)); } - template = template.concat(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)); + for (const item of menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)) { + menu.append(new MenuItem(item)); + } - const menu = bridge().Menu.buildFromTemplate(template); - menu.popup({ window: bridge().window() }); + menu.popup({ window: targetWindow }); } - bridge().window().webContents.on('context-menu', onContextMenu); + targetWindow.webContents.prependListener('context-menu', onContextMenu); return () => { - if (bridge().window()?.webContents?.off) { - bridge().window().webContents.off('context-menu', onContextMenu); + if (!targetWindow.isDestroyed() && targetWindow?.webContents?.off) { + targetWindow.webContents.off('context-menu', onContextMenu); } }; }, [editor, plugins, dispatch, htmlToMd, mdToHtml]); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.ts index 3ca36e681de..8ea5dd5cf9b 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.ts @@ -2,13 +2,14 @@ import PluginService from '@joplin/lib/services/plugins/PluginService'; import { useEffect } from 'react'; import { Editor } from 'tinymce'; -const useWebViewApi = (editor: Editor) => { +const useWebViewApi = (editor: Editor, window: Window) => { useEffect(() => { if (!editor) return ()=>{}; + if (!window) return ()=>{}; - const scriptElement = document.createElement('script'); + const scriptElement = window.document.createElement('script'); const channelId = `plugin-post-message-${Math.random()}`; - scriptElement.appendChild(document.createTextNode(` + scriptElement.appendChild(window.document.createTextNode(` window.webviewApi = { postMessage: (contentScriptId, message) => { const channelId = ${JSON.stringify(channelId)}; @@ -66,7 +67,7 @@ const useWebViewApi = (editor: Editor) => { window.removeEventListener('message', onMessageHandler); scriptElement.remove(); }; - }, [editor]); + }, [editor, window]); }; export default useWebViewApi; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index ff9ee971bd3..6c1906a5d7b 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo, useContext } from 'react'; import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; import { connect } from 'react-redux'; import MultiNoteActions from '../MultiNoteActions'; @@ -51,6 +51,8 @@ import { MarkupLanguage } from '@joplin/renderer'; import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions'; import useScheduleSaveCallbacks from './utils/useScheduleSaveCallbacks'; import WarningBanner from './WarningBanner/WarningBanner'; +import { stateUtils } from '@joplin/lib/reducer'; +import { WindowIdContext } from '../NewWindowOrIFrame'; const debounce = require('debounce'); const commands = [ @@ -59,7 +61,10 @@ const commands = [ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance()); -function NoteEditor(props: NoteEditorProps) { +const onDragOver: React.DragEventHandler = event => event.preventDefault(); +let editorIdCounter = 0; + +function NoteEditorContent(props: NoteEditorProps) { const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); const [isReadOnly, setIsReadOnly] = useState(false); @@ -69,9 +74,14 @@ function NoteEditor(props: NoteEditorProps) { const isMountedRef = useRef(true); const noteSearchBarRef = useRef(null); + // Should be constant and unique to this instance of the editor. + const editorId = useMemo(() => { + return `editor-${editorIdCounter++}`; + }, []); + const setFormNoteRef = useRef(); const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({ - setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, + setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId, }); const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => { await saveNoteIfWillChange(event.formNote); @@ -85,14 +95,13 @@ function NoteEditor(props: NoteEditorProps) { const effectiveNoteId = useEffectiveNoteId(props); const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ - syncStarted: props.syncStarted, - decryptionStarted: props.decryptionStarted, noteId: effectiveNoteId, isProvisional: props.isProvisional, titleInputRef: titleInputRef, editorRef: editorRef, onBeforeLoad: formNote_beforeLoad, onAfterLoad: formNote_afterLoad, + editorId, }); setFormNoteRef.current = setFormNote; const formNoteRef = useRef(); @@ -166,6 +175,10 @@ function NoteEditor(props: NoteEditorProps) { }, 100); }, [props.dispatch]); + useEffect(() => { + props.onTitleChange?.(formNote.title); + }, [formNote.title, props.onTitleChange]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onFieldChange = useCallback(async (field: string, value: any, changeId = 0) => { if (!isMountedRef.current) { @@ -225,6 +238,7 @@ function NoteEditor(props: NoteEditorProps) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onTitleChange = useCallback((event: any) => onFieldChange('title', event.target.value), [onFieldChange]); + const containerRef = useRef(null); useWindowCommandHandler({ dispatch: props.dispatch, setShowLocalSearch, @@ -232,6 +246,7 @@ function NoteEditor(props: NoteEditorProps) { editorRef, titleInputRef, onBodyChange, + containerRef, }); // const onTitleKeydown = useCallback((event:any) => { @@ -295,7 +310,8 @@ function NoteEditor(props: NoteEditorProps) { lastEditorScrollPercents: props.lastEditorScrollPercents, editorRef, }); - const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml); + const windowId = useContext(WindowIdContext); + const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const externalEditWatcher_noteChange = useCallback((event: any) => { @@ -340,12 +356,19 @@ function NoteEditor(props: NoteEditorProps) { useEffect(() => { const dependencies = { setShowRevisions, + isInFocusedDocument: () => { + return containerRef.current?.ownerDocument?.hasFocus(); + }, }; - CommandService.instance().componentRegisterCommands(dependencies, commands); + const registeredCommands = CommandService.instance().componentRegisterCommands( + dependencies, + commands, + true, + ); return () => { - CommandService.instance().componentUnregisterCommands(commands); + registeredCommands.deregister(); }; }, [setShowRevisions]); @@ -366,7 +389,7 @@ function NoteEditor(props: NoteEditorProps) { opacity: 0.1, ...rootStyle, }; - return
; + return
; } function renderTagButton() { @@ -464,10 +487,11 @@ function NoteEditor(props: NoteEditorProps) { padding: theme.margin, verticalAlign: 'top', boxSizing: 'border-box', + flex: 1, }; return ( -
+
); @@ -575,7 +599,7 @@ function NoteEditor(props: NoteEditorProps) { const theme = themeStyle(props.themeId); return ( -
+
{renderResourceWatchingNotification()} {renderResourceInSearchResultsNotification()} @@ -606,33 +630,40 @@ function NoteEditor(props: NoteEditorProps) { ); } -export { - NoteEditor as NoteEditorComponent, -}; +interface ConnectProps { + windowId: string; +} -const mapStateToProps = (state: AppState) => { - const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null; - const whenClauseContext = stateToWhenClauseContext(state); +const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { + const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); + const windowState = stateUtils.windowStateById(state, ownProps.windowId); + const noteId = stateUtils.selectedNoteId(windowState); + + let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE'; + if (state.settings.isSafeMode) { + bodyEditor = 'PlainText'; + } else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) { + bodyEditor = 'CodeMirror5'; + } return { - noteId: noteId, - notes: state.notes, - selectedNoteIds: state.selectedNoteIds, - selectedFolderId: state.selectedFolderId, + noteId, + bodyEditor, isProvisional: state.provisionalNoteIds.includes(noteId), + notes: windowState.notes, + selectedNoteIds: windowState.selectedNoteIds, + selectedFolderId: windowState.selectedFolderId, editorNoteStatuses: state.editorNoteStatuses, - syncStarted: state.syncStarted, - decryptionStarted: state.decryptionWorker?.state !== 'idle', themeId: state.settings.theme, watchedNoteFiles: state.watchedNoteFiles, - notesParentType: state.notesParentType, - selectedNoteTags: state.selectedNoteTags, + notesParentType: windowState.notesParentType, + selectedNoteTags: windowState.selectedNoteTags, lastEditorScrollPercents: state.lastEditorScrollPercents, - selectedNoteHash: state.selectedNoteHash, + selectedNoteHash: windowState.selectedNoteHash, searches: state.searches, - selectedSearchId: state.selectedSearchId, - customCss: state.customCss, - noteVisiblePanes: state.noteVisiblePanes, + selectedSearchId: windowState.selectedSearchId, + customCss: state.customViewerCss, + noteVisiblePanes: windowState.noteVisiblePanes, watchedResources: state.watchedResources, highlightedWords: state.highlightedWords, plugins: state.pluginService.plugins, @@ -654,4 +685,4 @@ const mapStateToProps = (state: AppState) => { }; }; -export default connect(mapStateToProps)(NoteEditor); +export default connect(mapStateToProps)(NoteEditorContent); diff --git a/packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx b/packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx index a863efc30a0..0841ae73c3c 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { _ } from '@joplin/lib/locale'; import CommandService from '@joplin/lib/services/CommandService'; -import { ChangeEvent, useCallback, useRef } from 'react'; +import { ChangeEvent, useCallback, useContext, useRef } from 'react'; import NoteToolbar from '../../NoteToolbar/NoteToolbar'; import { buildStyle } from '@joplin/lib/theme'; import time from '@joplin/lib/time'; +import { WindowIdContext } from '../../NewWindowOrIFrame'; interface Props { themeId: number; @@ -97,11 +98,14 @@ export default function NoteTitleBar(props: Props) { return {time.formatMsToLocal(props.noteUserUpdatedTime)}; } + const windowId = useContext(WindowIdContext); + function renderNoteToolbar() { return ; } diff --git a/packages/app-desktop/gui/NoteEditor/commands/showRevisions.ts b/packages/app-desktop/gui/NoteEditor/commands/showRevisions.ts index 7eea8396026..1f0c40fd443 100644 --- a/packages/app-desktop/gui/NoteEditor/commands/showRevisions.ts +++ b/packages/app-desktop/gui/NoteEditor/commands/showRevisions.ts @@ -10,5 +10,8 @@ export const runtime = (comp: any): CommandRuntime => { execute: async () => { comp.setShowRevisions(true); }, + getPriority: () => { + return comp.isInFocusedDocument() ? 1 : 0; + }, }; }; diff --git a/packages/app-desktop/gui/NoteEditor/style.scss b/packages/app-desktop/gui/NoteEditor/style.scss index 6fe97444de1..b5436a4df6f 100644 --- a/packages/app-desktop/gui/NoteEditor/style.scss +++ b/packages/app-desktop/gui/NoteEditor/style.scss @@ -3,3 +3,4 @@ @use "./styles/warning-banner-link.scss"; @use "./styles/note-title-info-group.scss"; @use "./styles/note-title-wrapper.scss"; +@use "./styles/note-editor-wrapper.scss"; diff --git a/packages/app-desktop/gui/NoteEditor/styles/note-editor-wrapper.scss b/packages/app-desktop/gui/NoteEditor/styles/note-editor-wrapper.scss new file mode 100644 index 00000000000..9359a1c6f74 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/styles/note-editor-wrapper.scss @@ -0,0 +1,8 @@ + +.note-editor-wrapper { + display: flex; + flex-grow: 1; + flex-shrink: 1; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.ts b/packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.ts new file mode 100644 index 00000000000..08b759a9f3d --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.ts @@ -0,0 +1,15 @@ +import { RefObject } from 'react'; + +const getWindowCommandPriority = (contentContainer: RefObject) => { + if (!contentContainer.current) return 0; + const containerDocument = contentContainer.current.getRootNode() as Document; + if (!containerDocument || !containerDocument.hasFocus()) return 0; + + if (contentContainer.current.contains(containerDocument.activeElement)) { + return 2; + } + + // Container document has focus, but not this editor. + return 1; +}; +export default getWindowCommandPriority; diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 7d17bf4602c..f5a498ed7a6 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -30,9 +30,6 @@ export interface NoteEditorProps { isProvisional: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied editorNoteStatuses: any; - syncStarted: boolean; - decryptionStarted: boolean; - bodyEditor: string; notesParentType: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied selectedNoteTags: any[]; @@ -57,6 +54,9 @@ export interface NoteEditorProps { shareCacheSetting: string; syncUserId: string; searchResults: ProcessResultsRow[]; + + onTitleChange?: (title: string)=> void; + bodyEditor: string; } export interface NoteBodyEditorRef { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts index 08d742ac159..68b9cef3fa3 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts @@ -8,14 +8,13 @@ import { join } from 'path'; import { formNoteToNote } from '.'; const defaultFormNoteProps: HookDependencies = { - syncStarted: false, - decryptionStarted: false, noteId: '', isProvisional: false, titleInputRef: null, editorRef: null, - onBeforeLoad: ()=>{}, - onAfterLoad: ()=>{}, + onBeforeLoad: () => { }, + onAfterLoad: () => { }, + editorId: 'editor', }; describe('useFormNote', () => { @@ -27,58 +26,57 @@ describe('useFormNote', () => { it('should update note when decryption completes', async () => { const testNote = await Note.save({ title: 'Test Note!' }); - const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => { + const makeFormNoteProps = (): HookDependencies => { return { ...defaultFormNoteProps, - syncStarted, - decryptionStarted, noteId: testNote.id, }; }; const formNote = renderHook(props => useFormNote(props), { - initialProps: makeFormNoteProps(true, false), + initialProps: makeFormNoteProps(), }); await formNote.waitFor(() => { - expect(formNote.result.current.formNote).toMatchObject({ - encryption_applied: 0, - title: testNote.title, - }); + // id is falsy until after the first load of the form note. + expect(formNote.result.current.formNote.id).not.toBeFalsy(); }); - - await Note.save({ - id: testNote.id, - encryption_cipher_text: 'cipher_text', - encryption_applied: 1, + expect(formNote.result.current.formNote).toMatchObject({ + encryption_applied: 0, + title: testNote.title, }); - // Sync starting should cause a re-render - formNote.rerender(makeFormNoteProps(false, false)); - - await formNote.waitFor(() => { - expect(formNote.result.current.formNote).toMatchObject({ + await act(async () => { + await Note.save({ + id: testNote.id, + encryption_cipher_text: 'cipher_text', encryption_applied: 1, }); }); + // Changing encryption_applied should cause a re-render + await act(async () => { + await formNote.waitFor(() => { + expect(formNote.result.current.formNote).toMatchObject({ + encryption_applied: 1, + }); + }); + }); - formNote.rerender(makeFormNoteProps(false, true)); - - await Note.save({ - id: testNote.id, - encryption_applied: 0, - title: 'Test Note!', + await act(async () => { + await Note.save({ + id: testNote.id, + encryption_applied: 0, + title: 'Test Note!', + }); }); // Ending decryption should also cause a re-render - formNote.rerender(makeFormNoteProps(false, false)); - await formNote.waitFor(() => { expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 0, - title: 'Test Note!', }); - }); + // A larger-than-default timeout is needed to prevent CI failures: + }, { timeout: 5_000 }); formNote.unmount(); }); @@ -116,37 +114,33 @@ describe('useFormNote', () => { formNote.unmount(); }); - // It seems this test is crashing the worker on CI (out of memory), so disabling it for now. + it('should reload the note when it is changed outside of the editor', async () => { + const note = await Note.save({ title: 'Test Note!', body: '...' }); - // it('should reload the note when it is changed outside of the editor', async () => { - // const note = await Note.save({ title: 'Test Note!' }); - - // const makeFormNoteProps = (dbNote: DbNote): HookDependencies => { - // return { - // ...defaultFormNoteProps, - // noteId: note.id, - // dbNote, - // }; - // }; + const props = { + ...defaultFormNoteProps, + noteId: note.id, + }; - // const formNote = renderHook(props => useFormNote(props), { - // initialProps: makeFormNoteProps({ id: note.id, updated_time: note.updated_time }), - // }); + const formNote = renderHook(props => useFormNote(props), { + initialProps: props, + }); - // await formNote.waitFor(() => { - // expect(formNote.result.current.formNote.title).toBe('Test Note!'); - // }); + await formNote.waitFor(() => { + expect(formNote.result.current.formNote.title).toBe('Test Note!'); + }); - // // Simulate the note being modified outside the editor - // const modifiedNote = await Note.save({ id: note.id, title: 'Modified' }); + // Simulate the note being modified outside the editor + await act(async () => { + await Note.save({ id: note.id, title: 'Modified' }); + }); - // // NoteEditor then would update `dbNote` - // formNote.rerender(makeFormNoteProps({ id: note.id, updated_time: modifiedNote.updated_time })); + await formNote.waitFor(() => { + expect(formNote.result.current.formNote.title).toBe('Modified'); + }); - // await formNote.waitFor(() => { - // expect(formNote.result.current.formNote.title).toBe('Modified'); - // }); - // }); + formNote.unmount(); + }); test('should refresh resource infos when changed outside the editor', async () => { let note = await Note.save({}); @@ -154,17 +148,15 @@ describe('useFormNote', () => { const resourceIds = Note.linkedItemIds(note.body); const resource = await Resource.load(resourceIds[0]); - const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => { + const makeFormNoteProps = (): HookDependencies => { return { ...defaultFormNoteProps, - syncStarted, - decryptionStarted, noteId: note.id, }; }; const formNote = renderHook(props => useFormNote(props), { - initialProps: makeFormNoteProps(true, false), + initialProps: makeFormNoteProps(), }); await formNote.waitFor(() => { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts index 17ab6decaa1..7f60655bd5b 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts @@ -14,6 +14,7 @@ import { focus } from '@joplin/lib/utils/focusHandler'; import Logger from '@joplin/utils/Logger'; import eventManager, { EventName } from '@joplin/lib/eventManager'; import DecryptionWorker from '@joplin/lib/services/DecryptionWorker'; +import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect'; const logger = Logger.create('useFormNote'); @@ -22,9 +23,8 @@ export interface OnLoadEvent { } export interface HookDependencies { - syncStarted: boolean; - decryptionStarted: boolean; noteId: string; + editorId: string; isProvisional: boolean; titleInputRef: RefObject; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -66,26 +66,85 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean { return false; } +type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise; +const useRefreshFormNoteOnChange = (formNoteRef: RefObject, editorId: string, noteId: string, initNoteState: InitNoteStateCallback) => { + // Increasing the value of this counter cancels any ongoing note refreshes and starts + // a new refresh. + const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState(0); + + useQueuedAsyncEffect(async (event) => { + if (formNoteRefreshScheduled <= 0) return; + if (formNoteRef.current.hasChanged) { + logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.'); + return; + } + + logger.info('Sync has finished and note has never been changed - reloading it'); + + const loadNote = async () => { + const n = await Note.load(noteId); + if (event.cancelled || formNoteRef.current.hasChanged) return; + + // Normally should not happened because if the note has been deleted via sync + // it would not have been loaded in the editor (due to note selection changing + // on delete) + if (!n) { + logger.warn('Trying to reload note that has been deleted:', noteId); + return; + } + + await initNoteState(n, false); + if (event.cancelled) return; + setFormNoteRefreshScheduled(0); + }; + + await loadNote(); + }, [formNoteRefreshScheduled, noteId, editorId, initNoteState]); + + const refreshFormNote = useCallback(() => { + // Increase the counter to cancel any ongoing refresh attempts + // and start a new one. + setFormNoteRefreshScheduled(formNoteRefreshScheduled + 1); + }, [formNoteRefreshScheduled]); + + useEffect(() => { + if (!noteId) return ()=>{}; + + let cancelled = false; + + type ChangeEventSlice = { itemId: string; changeId: string }; + const listener = ({ itemId, changeId }: ChangeEventSlice) => { + // If this change came from the current editor, it should already be + // handled by calls to `setFormNote`. If events from the current editor + // aren't ignored, most user-activated note changes (e.g. a keypress) + // cause the note to refresh. (Undesired refreshes can cause the cursor to jump). + const isExternalChange = !(changeId ?? 'unknown').endsWith(editorId); + if (itemId === noteId && !cancelled && isExternalChange) { + if (formNoteRef.current.hasChanged) return; + refreshFormNote(); + } + }; + eventManager.on(EventName.ItemChange, listener); + + return () => { + eventManager.off(EventName.ItemChange, listener); + cancelled = true; + }; + }, [formNoteRef, noteId, editorId, refreshFormNote]); +}; + export default function useFormNote(dependencies: HookDependencies) { - const { - syncStarted, decryptionStarted, noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, - } = dependencies; + const { noteId, editorId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies; const [formNote, setFormNote] = useState(defaultFormNote()); const [isNewNote, setIsNewNote] = useState(false); - const prevSyncStarted = usePrevious(syncStarted); - const prevDecryptionStarted = usePrevious(decryptionStarted); const previousNoteId = usePrevious(formNote.id); const [resourceInfos, setResourceInfos] = useState({}); const formNoteRef = useRef(formNote); formNoteRef.current = formNote; - // Increasing the value of this counter cancels any ongoing note refreshes and starts - // a new refresh. - const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState(0); - - const initNoteState = useCallback(async (n: NoteEntity, isNewNote: boolean) => { + const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => { let originalCss = ''; if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) { @@ -125,9 +184,9 @@ export default function useFormNote(dependencies: HookDependencies) { logger.info('Cancelled note refresh -- form note changed while loading attached resources.'); return null; } - setResourceInfos(resources); setFormNote(newFormNote); + formNoteRef.current = newFormNote; logger.debug('Resource info and form note set.'); @@ -136,69 +195,7 @@ export default function useFormNote(dependencies: HookDependencies) { return newFormNote; }, []); - useEffect(() => { - if (formNoteRefreshScheduled <= 0) return () => {}; - if (formNoteRef.current.hasChanged) { - logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.'); - return () => {}; - } - - logger.info('Sync has finished and note has never been changed - reloading it'); - - let cancelled = false; - - const loadNote = async () => { - const n = await Note.load(noteId); - if (cancelled) return; - - // Normally should not happened because if the note has been deleted via sync - // it would not have been loaded in the editor (due to note selection changing - // on delete) - if (!n) { - logger.warn('Trying to reload note that has been deleted:', noteId); - return; - } - - await initNoteState(n, false); - - setFormNoteRefreshScheduled(0); - }; - - void loadNote(); - - return () => { - cancelled = true; - }; - }, [formNoteRefreshScheduled, noteId, initNoteState]); - - const refreshFormNote = useCallback(() => { - // Increase the counter to cancel any ongoing refresh attempts - // and start a new one. - setFormNoteRefreshScheduled(formNoteRefreshScheduled + 1); - }, [formNoteRefreshScheduled]); - - useEffect(() => { - // Check that synchronisation has just finished - and - // if the note has never been changed, we reload it. - // If the note has already been changed, it's a conflict - // that's already been handled by the synchronizer. - const decryptionJustEnded = prevDecryptionStarted && !decryptionStarted; - const syncJustEnded = prevSyncStarted && !syncStarted; - - if (!decryptionJustEnded && !syncJustEnded) return; - if (formNoteRef.current.hasChanged) return; - - logger.debug('Sync or decryption finished with an unchanged formNote.'); - - // Refresh the form note. - // This is kept separate from the above logic so that when prevSyncStarted is changed - // from true to false, it doesn't cancel the note from loading. - refreshFormNote(); - }, [ - prevSyncStarted, syncStarted, - prevDecryptionStarted, decryptionStarted, - refreshFormNote, - ]); + useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState); useEffect(() => { if (!noteId) { @@ -296,14 +293,14 @@ export default function useFormNote(dependencies: HookDependencies) { // changes, with no delay during which async code can run. Even a small delay (e.g. that introduced // by a setState -> useEffect) can lead to a race condition. See https://github.com/laurent22/joplin/issues/8960. const onSetFormNote: OnSetFormNote = useCallback(newFormNote => { + let newNote; if (typeof newFormNote === 'function') { - const newNote = newFormNote(formNoteRef.current); - formNoteRef.current = newNote; - setFormNote(newNote); + newNote = newFormNote(formNoteRef.current); } else { - formNoteRef.current = newFormNote; - setFormNote(newFormNote); + newNote = newFormNote; } + formNoteRef.current = newNote; + setFormNote(newNote); }, [setFormNote]); return { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts index bc65a56aa0f..5c15325bba3 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.ts @@ -5,10 +5,22 @@ import CommandService from '@joplin/lib/services/CommandService'; import PostMessageService from '@joplin/lib/services/PostMessageService'; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher'; import { reg } from '@joplin/lib/registry'; -const bridge = require('@electron/remote').require('./bridge').default; +import bridge from '../../../services/bridge'; -// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied -export default function useMessageHandler(scrollWhenReady: ScrollOptions|null, clearScrollWhenReady: ()=> void, editorRef: any, setLocalSearchResultCount: Function, dispatch: Function, formNote: FormNote, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) { +export default function useMessageHandler( + scrollWhenReady: ScrollOptions|null, + clearScrollWhenReady: ()=> void, + windowId: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + editorRef: any, + // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied + setLocalSearchResultCount: Function, + // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied + dispatch: Function, + formNote: FormNote, + htmlToMd: HtmlToMarkdownHandler, + mdToHtml: MarkupToHtmlHandler, +) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied return useCallback(async (event: any) => { const msg = event.channel ? event.channel : ''; @@ -49,7 +61,7 @@ export default function useMessageHandler(scrollWhenReady: ScrollOptions|null, c mdToHtml, }, dispatch); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); } else if (msg.indexOf('#') === 0) { // This is an internal anchor, which is handled by the WebView so skip this case } else if (msg === 'contentScriptExecuteCommand') { @@ -57,7 +69,7 @@ export default function useMessageHandler(scrollWhenReady: ScrollOptions|null, c const commandArgs = arg0.args || []; void CommandService.instance().execute(commandName, ...commandArgs); } else if (msg === 'postMessageService.message') { - void PostMessageService.instance().postMessage(arg0); + void PostMessageService.instance().postMessage({ ...arg0, windowId }); } else if (msg === 'openPdfViewer') { await CommandService.instance().execute('openPdfViewer', arg0.resourceId, arg0.pageNo); } else { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.ts b/packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.ts index 60249005d32..38a3e54b3df 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.ts @@ -12,6 +12,7 @@ const logger = Logger.create('useScheduleSaveCallbacks'); interface Props { setFormNote: RefObject; + editorId: string; dispatch: Dispatch; editorRef: RefObject; } @@ -26,7 +27,7 @@ const useScheduleSaveCallbacks = (props: Props) => { return async function() { const note = await formNoteToNote(formNote); logger.debug('Saving note...', note); - const savedNote = await Note.save(note); + const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` }); props.setFormNote.current((prev: FormNote) => { return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false }; @@ -45,7 +46,7 @@ const useScheduleSaveCallbacks = (props: Props) => { formNote.saveActionQueue.push(makeAction(formNote)); return formNote.saveActionQueue.waitForAllDone(); - }, [props.dispatch, props.setFormNote]); + }, [props.dispatch, props.editorId, props.setFormNote]); const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => { if (!formNote.id || !formNote.bodyWillChangeId) return; diff --git a/packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.ts b/packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.ts index c8aa14e01d6..595d51e0c4e 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.ts @@ -1,9 +1,10 @@ import { RefObject, useEffect } from 'react'; import { NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types'; import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations'; -import CommandService, { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService'; +import CommandService, { CommandDeclaration, CommandRuntime, CommandContext, RegisteredRuntime } from '@joplin/lib/services/CommandService'; import time from '@joplin/lib/time'; import { reg } from '@joplin/lib/registry'; +import getWindowCommandPriority from './getWindowCommandPriority'; const commandsWithDependencies = [ require('../commands/showLocalSearch'), @@ -24,6 +25,7 @@ interface HookDependencies { editorRef: RefObject; titleInputRef: RefObject; onBodyChange: OnBodyChange; + containerRef: RefObject; } function editorCommandRuntime( @@ -76,11 +78,19 @@ function editorCommandRuntime( } export default function useWindowCommandHandler(dependencies: HookDependencies) { - const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange } = dependencies; + const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange, containerRef } = dependencies; useEffect(() => { + const getRuntimePriority = () => getWindowCommandPriority(containerRef); + + const deregisterCallbacks: RegisteredRuntime[] = []; for (const declaration of editorCommandDeclarations) { - CommandService.instance().registerRuntime(declaration.name, editorCommandRuntime(declaration, editorRef, onBodyChange)); + const runtime = editorCommandRuntime(declaration, editorRef, onBodyChange); + deregisterCallbacks.push(CommandService.instance().registerRuntime( + declaration.name, + { ...runtime, getPriority: getRuntimePriority }, + true, + )); } const dependencies = { @@ -91,17 +101,18 @@ export default function useWindowCommandHandler(dependencies: HookDependencies) }; for (const command of commandsWithDependencies) { - CommandService.instance().registerRuntime(command.declaration.name, command.runtime(dependencies)); + const runtime = command.runtime(dependencies); + deregisterCallbacks.push(CommandService.instance().registerRuntime( + command.declaration.name, + { ...runtime, getPriority: getRuntimePriority }, + true, + )); } return () => { - for (const declaration of editorCommandDeclarations) { - CommandService.instance().unregisterRuntime(declaration.name); - } - - for (const command of commandsWithDependencies) { - CommandService.instance().unregisterRuntime(command.declaration.name); + for (const runtime of deregisterCallbacks) { + runtime.deregister(); } }; - }, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange]); + }, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange, containerRef]); } diff --git a/packages/app-desktop/gui/NoteList/NoteList2.tsx b/packages/app-desktop/gui/NoteList/NoteList2.tsx index 7c47bb25517..027e3f123e2 100644 --- a/packages/app-desktop/gui/NoteList/NoteList2.tsx +++ b/packages/app-desktop/gui/NoteList/NoteList2.tsx @@ -27,7 +27,8 @@ import { _ } from '@joplin/lib/locale'; import useActiveDescendantId from './utils/useActiveDescendantId'; import getNoteElementIdFromJoplinId from '../NoteListItem/utils/getNoteElementIdFromJoplinId'; import useFocusVisible from './utils/useFocusVisible'; -const { connect } = require('react-redux'); +import { stateUtils } from '@joplin/lib/reducer'; +import { connect } from 'react-redux'; const commands = { focusElementNoteList, @@ -311,19 +312,24 @@ const NoteList = (props: Props) => { ); }; -const mapStateToProps = (state: AppState) => { +interface ConnectProps { + windowId: string; +} + +const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { const selectedFolder: FolderEntity = state.notesParentType === 'Folder' ? Folder.byId(state.folders, state.selectedFolderId) : null; const userId = state.settings['sync.userId']; + const windowState = stateUtils.windowStateById(state, ownProps.windowId); return { - notes: state.notes, + notes: windowState.notes, folders: state.folders, - selectedNoteIds: state.selectedNoteIds, - selectedFolderId: state.selectedFolderId, + selectedNoteIds: windowState.selectedNoteIds, + selectedFolderId: windowState.selectedFolderId, themeId: state.settings.theme, notesParentType: state.notesParentType, searches: state.searches, - selectedSearchId: state.selectedSearchId, + selectedSearchId: windowState.selectedSearchId, watchedNoteFiles: state.watchedNoteFiles, provisionalNoteIds: state.provisionalNoteIds, isInsertingNotes: state.isInsertingNotes, @@ -332,7 +338,7 @@ const mapStateToProps = (state: AppState) => { showCompletedTodos: state.settings.showCompletedTodos, highlightedWords: state.highlightedWords, plugins: state.pluginService.plugins, - customCss: state.customCss, + customCss: state.customViewerCss, focusedField: state.focusedField, parentFolderIsReadOnly: state.notesParentType === 'Folder' && selectedFolder ? itemIsReadOnlySync(ModelType.Folder, ItemChange.SOURCE_UNSPECIFIED, selectedFolder as ItemSlice, userId, state.shareService) : false, selectedFolderInTrash: itemIsInTrash(selectedFolder), diff --git a/packages/app-desktop/gui/NoteList/commands/focusElementNoteList.ts b/packages/app-desktop/gui/NoteList/commands/focusElementNoteList.ts index dbedcf93b52..07871933819 100644 --- a/packages/app-desktop/gui/NoteList/commands/focusElementNoteList.ts +++ b/packages/app-desktop/gui/NoteList/commands/focusElementNoteList.ts @@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/ import { _ } from '@joplin/lib/locale'; import { stateUtils } from '@joplin/lib/reducer'; import { FocusNote } from '../utils/useFocusNote'; +import bridge from '../../../services/bridge'; export const declaration: CommandDeclaration = { name: 'focusElementNoteList', @@ -14,6 +15,10 @@ export const runtime = (focusNote: FocusNote): CommandRuntime => { execute: async (context: CommandContext, noteId: string = null) => { noteId = noteId || stateUtils.selectedNoteId(context.state); focusNote(noteId); + + // The sidebar is only present in the main window. If a different window + // is active, the main window needs to be shown. + bridge().switchToMainWindow(); }, enabledCondition: 'noteListHasNotes', }; diff --git a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx index b3f9ffbc415..fe60a3b0c6b 100644 --- a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx +++ b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx @@ -8,11 +8,12 @@ import { runtime as focusSearchRuntime } from './commands/focusSearch'; import Note from '@joplin/lib/models/Note'; import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils'; import { _ } from '@joplin/lib/locale'; -const { connect } = require('react-redux'); +import { connect } from 'react-redux'; import styled from 'styled-components'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import { getTrashFolderId } from '@joplin/lib/services/trash'; import { Breakpoints } from '../NoteList/utils/types'; +import { stateUtils } from '@joplin/lib/reducer'; interface Props { showNewNoteButtons: boolean; @@ -274,17 +275,22 @@ function NoteListControls(props: Props) { ); } -const mapStateToProps = (state: AppState) => { - const whenClauseContext = stateToWhenClauseContext(state); +interface ConnectProps { + windowId: string; +} + +const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { + const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); + const windowState = stateUtils.windowStateById(state, ownProps.windowId); return { - showNewNoteButtons: state.selectedFolderId !== getTrashFolderId(), + showNewNoteButtons: windowState.selectedFolderId !== getTrashFolderId(), newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext), newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext), sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'], sortOrderField: state.settings['notes.sortOrder.field'], sortOrderReverse: state.settings['notes.sortOrder.reverse'], - notesParentType: state.notesParentType, + notesParentType: windowState.notesParentType, }; }; diff --git a/packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.ts index 5411622d045..3d9c6ee8c04 100644 --- a/packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.ts +++ b/packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.ts @@ -46,6 +46,6 @@ export default (columns: NoteListColumns) => { const menu = Menu.buildFromTemplate(menuItems); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().mainWindow() }); }, [columns]); }; diff --git a/packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.ts b/packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.ts index 1579e2a67e4..6389733d916 100644 --- a/packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.ts +++ b/packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.ts @@ -50,7 +50,7 @@ const useOnContextMenu = ( customCss: customCss, }); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().mainWindow() }); }, [selectedNoteIds, notes, dispatch, watchedNoteFiles, plugins, selectedFolderId, customCss]); }; diff --git a/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx b/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx index 96af332d531..ef451fe3e14 100644 --- a/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx +++ b/packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx @@ -1,6 +1,6 @@ import { themeStyle } from '@joplin/lib/theme'; import * as React from 'react'; -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect, useCallback, useContext } from 'react'; import NoteList2 from '../NoteList/NoteList2'; import NoteListControls from '../NoteListControls/NoteListControls'; import { Size } from '../ResizableLayout/utils/types'; @@ -17,6 +17,7 @@ import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType'; import depNameToNoteProp from '@joplin/lib/services/noteList/depNameToNoteProp'; import { getTrashFolderId } from '@joplin/lib/services/trash'; import usePrevious from '../hooks/usePrevious'; +import { WindowIdContext } from '../NewWindowOrIFrame'; const logger = Logger.create('NoteListWrapper'); @@ -163,9 +164,11 @@ export default function NoteListWrapper(props: Props) { />; }; + const windowId = useContext(WindowIdContext); const renderNoteList = () => { if (!listRenderer) return null; return {renderHeader()} {renderNoteList()} diff --git a/packages/app-desktop/gui/NoteTextViewer.tsx b/packages/app-desktop/gui/NoteTextViewer.tsx index 43ac992e7b0..c34f534ff6e 100644 --- a/packages/app-desktop/gui/NoteTextViewer.tsx +++ b/packages/app-desktop/gui/NoteTextViewer.tsx @@ -3,6 +3,9 @@ import * as React from 'react'; import { reg } from '@joplin/lib/registry'; import bridge from '../services/bridge'; import { focus } from '@joplin/lib/utils/focusHandler'; +import { ForwardedRef, forwardRef, RefObject, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { WindowIdContext } from './NewWindowOrIFrame'; +import useDocument from './hooks/useDocument'; interface Props { // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied @@ -15,230 +18,215 @@ interface Props { themeId: number; } -type RemovePluginAssetsCallback = ()=> void; interface SetHtmlOptions { pluginAssets: { path: string }[]; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export default class NoteTextViewerComponent extends React.Component { - - private initialized_ = false; - private domReady_ = false; - private webviewRef_: React.RefObject; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private webviewListeners_: any = null; - - private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public constructor(props: any) { - super(props); +export interface NoteViewerControl { + domReady(): boolean; + setHtml(html: string, options: SetHtmlOptions): void; + send(channel: string, arg0?: unknown, arg1?: unknown): void; + focus(): void; + hasFocus(): boolean; +} - this.webviewRef_ = React.createRef(); +const usePluginMessageResponder = (webviewRef: RefObject) => { + const windowId = useContext(WindowIdContext); - PostMessageService.instance().registerResponder(ResponderComponentType.NoteTextViewer, '', (message: MessageResponse) => { - if (!this.webviewRef_?.current?.contentWindow) { + useEffect(() => { + PostMessageService.instance().registerResponder(ResponderComponentType.NoteTextViewer, '', windowId, (message: MessageResponse) => { + if (!webviewRef?.current?.contentWindow) { reg.logger().warn('Cannot respond to message because target is gone', message); return; } - this.webviewRef_.current.contentWindow.postMessage({ + webviewRef.current.contentWindow.postMessage({ target: 'webview', name: 'postMessageService.response', data: message, }, '*'); }); - this.webview_domReady = this.webview_domReady.bind(this); - this.webview_ipcMessage = this.webview_ipcMessage.bind(this); - this.webview_load = this.webview_load.bind(this); - this.webview_message = this.webview_message.bind(this); - } + return () => { + PostMessageService.instance().unregisterResponder(ResponderComponentType.NoteTextViewer, '', windowId); + }; + }, [webviewRef, windowId]); +}; + +const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef) => { + const [webview, setWebview] = useState(null); + const webviewRef = useRef(null); + webviewRef.current = webview; + usePluginMessageResponder(webviewRef); + + const domReadyRef = useRef(false); + type RemovePluginAssetsCallback = ()=> void; + const removePluginAssetsCallbackRef = useRef(null); + + const parentDoc = useDocument(webview); + const containerWindow = parentDoc?.defaultView; + + useImperativeHandle(ref, () => { + const result: NoteViewerControl = { + domReady: () => domReadyRef.current, + setHtml: (html: string, options: SetHtmlOptions) => { + const protocolHandler = bridge().electronApp().getCustomProtocolHandler(); + + // Grant & remove asset access. + if (options.pluginAssets) { + removePluginAssetsCallbackRef.current?.(); + + const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path); + const assetAccesses = pluginAssetPaths.map( + path => protocolHandler.allowReadAccessToFile(path), + ); + + removePluginAssetsCallbackRef.current = () => { + for (const accessControl of assetAccesses) { + accessControl.remove(); + } + + removePluginAssetsCallbackRef.current = null; + }; + } + + result.send('setHtml', html, { + ...options, + mediaAccessKey: protocolHandler.getMediaAccessKey(), + }); + }, + send: (channel: string, arg0: unknown = null, arg1: unknown = null) => { + const win = webviewRef.current?.contentWindow; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private webview_domReady(event: any) { - this.domReady_ = true; - if (this.props.onDomReady) this.props.onDomReady(event); - } + // Window may already be closed + if (!win) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private webview_ipcMessage(event: any) { - if (this.props.onIpcMessage) this.props.onIpcMessage(event); - } + if (channel === 'focus') { + win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*'); + } + + // External code should use .setHtml (rather than send('setHtml', ...)) + if (channel === 'setHtml') { + win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*'); + } + + if (channel === 'scrollToHash') { + win.postMessage({ target: 'webview', name: 'scrollToHash', data: { hash: arg0 } }, '*'); + } - private webview_load() { - this.webview_domReady({}); - } + if (channel === 'setPercentScroll') { + win.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: arg0 } }, '*'); + } - private webview_message(event: MessageEvent) { - if (event.source !== this.webviewRef_.current?.contentWindow) return; + if (channel === 'setMarkers') { + win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*'); + } + }, + focus: () => { + if (webviewRef.current) { + // Calling focus on webviewRef seems to be necessary when NoteTextViewer.focus + // is called outside of a user event (e.g. in a setTimeout) or during automated + // tests: + focus('NoteTextViewer::focus', webviewRef.current); + + // Calling .focus on this.webviewRef.current isn't sufficient. + // To allow arrow-key scrolling, focus must also be set within the iframe: + result.send('focus'); + } + }, + hasFocus: () => { + return webviewRef.current?.contains(parentDoc.activeElement); + }, + }; + return result; + }, [parentDoc]); + + const webview_domReadyRef = useRef(); + webview_domReadyRef.current = (event: Event) => { + domReadyRef.current = true; + if (props.onDomReady) props.onDomReady(event); + }; + + const webview_ipcMessageRef = useRef(); + webview_ipcMessageRef.current = (event: Event) => { + if (props.onIpcMessage) props.onIpcMessage(event); + }; + + const webview_loadRef = useRef(); + webview_loadRef.current = (event: Event) => { + webview_domReadyRef.current(event); + }; + + type MessageEventListener = (event: MessageEvent)=> void; + const webview_messageRef = useRef(); + webview_messageRef.current = (event: MessageEvent) => { + if (event.source !== webviewRef.current?.contentWindow) return; if (!event.data || event.data.target !== 'main') return; const callName = event.data.name; const args = event.data.args; - if (this.props.onIpcMessage) { - this.props.onIpcMessage({ + if (props.onIpcMessage) { + props.onIpcMessage({ channel: callName, args: args, }); } - } + }; - public domReady() { - return this.domReady_; - } + useEffect(() => { + const wv = webviewRef.current; + if (!wv || !containerWindow) return () => {}; - public initWebview() { - const wv = this.webviewRef_.current; + const webviewListeners: Record = { + 'dom-ready': (event) => webview_domReadyRef.current(event), + 'ipc-message': (event) => webview_ipcMessageRef.current(event), + 'load': (event) => webview_loadRef.current(event), + }; - if (!this.webviewListeners_) { - this.webviewListeners_ = { - 'dom-ready': this.webview_domReady.bind(this), - 'ipc-message': this.webview_ipcMessage.bind(this), - 'load': this.webview_load.bind(this), - }; - } - - for (const n in this.webviewListeners_) { - if (!this.webviewListeners_.hasOwnProperty(n)) continue; - const fn = this.webviewListeners_[n]; + for (const n in webviewListeners) { + if (!webviewListeners.hasOwnProperty(n)) continue; + const fn = webviewListeners[n]; wv.addEventListener(n, fn); } - window.addEventListener('message', this.webview_message); - } - - private destroyWebview() { - const wv = this.webviewRef_.current; - if (!wv || !this.initialized_) return; - - for (const n in this.webviewListeners_) { - if (!this.webviewListeners_.hasOwnProperty(n)) continue; - const fn = this.webviewListeners_[n]; - wv.removeEventListener(n, fn); - } - - window.removeEventListener('message', this.webview_message); - - this.initialized_ = false; - this.domReady_ = false; + const messageListener: MessageEventListener = event => webview_messageRef.current(event); + containerWindow.addEventListener('message', messageListener); - this.removePluginAssetsCallback_?.(); - } - - public focus() { - if (this.webviewRef_.current) { - // Calling focus on webviewRef_ seems to be necessary when NoteTextViewer.focus - // is called outside of a user event (e.g. in a setTimeout) or during automated - // tests: - focus('NoteTextViewer::focus', this.webviewRef_.current); - - // Calling .focus on this.webviewRef.current isn't sufficient. - // To allow arrow-key scrolling, focus must also be set within the iframe: - this.send('focus'); - } - } + return () => { + domReadyRef.current = false; - public hasFocus() { - return this.webviewRef_.current?.contains(document.activeElement); - } + const wv = webviewRef.current; + if (!wv) return; - public tryInit() { - if (!this.initialized_ && this.webviewRef_.current) { - this.initWebview(); - this.initialized_ = true; - } - } - - public componentDidMount() { - this.tryInit(); - } - - public componentDidUpdate() { - this.tryInit(); - } - - public componentWillUnmount() { - this.destroyWebview(); - } - - // ---------------------------------------------------------------- - // Wrap WebView functions - // ---------------------------------------------------------------- - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public send(channel: string, arg0: any = null, arg1: any = null) { - const win = this.webviewRef_.current.contentWindow; - - if (channel === 'focus') { - win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*'); - } - - // External code should use .setHtml (rather than send('setHtml', ...)) - if (channel === 'setHtml') { - win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*'); - } - - if (channel === 'scrollToHash') { - win.postMessage({ target: 'webview', name: 'scrollToHash', data: { hash: arg0 } }, '*'); - } - - if (channel === 'setPercentScroll') { - win.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: arg0 } }, '*'); - } - - if (channel === 'setMarkers') { - win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*'); - } - } - - public setHtml(html: string, options: SetHtmlOptions) { - const protocolHandler = bridge().electronApp().getCustomProtocolHandler(); - - // Grant & remove asset access. - if (options.pluginAssets) { - this.removePluginAssetsCallback_?.(); - - const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path); - const assetAccesses = pluginAssetPaths.map( - path => protocolHandler.allowReadAccessToFile(path), - ); - - this.removePluginAssetsCallback_ = () => { - for (const accessControl of assetAccesses) { - accessControl.remove(); - } - - this.removePluginAssetsCallback_ = null; - }; - } + for (const n in webviewListeners) { + if (!webviewListeners.hasOwnProperty(n)) continue; + const fn = webviewListeners[n]; + wv.removeEventListener(n, fn); + } - this.send('setHtml', html, { - ...options, - mediaAccessKey: protocolHandler.getMediaAccessKey(), - }); - } - - // ---------------------------------------------------------------- - // Wrap WebView functions (END) - // ---------------------------------------------------------------- - - public render() { - const viewerStyle = { border: 'none', ...this.props.viewerStyle }; - - // allow=fullscreen: Required to allow the user to fullscreen videos. - return ( - - ); - } -} + containerWindow?.removeEventListener('message', messageListener); + + removePluginAssetsCallbackRef.current?.(); + }; + }, [containerWindow]); + + const viewerStyle = useMemo(() => { + return { border: 'none', ...props.viewerStyle }; + }, [props.viewerStyle]); + + // allow=fullscreen: Required to allow the user to fullscreen videos. + return ( + + ); +}); + +export default NoteTextViewer; diff --git a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx index 494ead619a9..1f2a894229e 100644 --- a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx +++ b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx @@ -4,9 +4,10 @@ import ToolbarBase from '../ToolbarBase'; import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; -const { connect } = require('react-redux'); +import { connect } from 'react-redux'; import { buildStyle } from '@joplin/lib/theme'; import { _ } from '@joplin/lib/locale'; +import { AppState } from '../../app.reducer'; interface NoteToolbarProps { themeId: number; @@ -42,9 +43,11 @@ function NoteToolbar(props: NoteToolbarProps) { const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance()); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -const mapStateToProps = (state: any) => { - const whenClauseContext = stateToWhenClauseContext(state); +interface ConnectProps { + windowId: string; +} +const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { + const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); return { toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([ diff --git a/packages/app-desktop/gui/PdfViewer.tsx b/packages/app-desktop/gui/PdfViewer.tsx index a6a5ebebc66..806615ea8b1 100644 --- a/packages/app-desktop/gui/PdfViewer.tsx +++ b/packages/app-desktop/gui/PdfViewer.tsx @@ -69,7 +69,7 @@ export default function PdfViewer(props: Props) { mdToHtml: async (_a, b, _c) => { return { html: b, pluginAssets: [], cssStrings: [] }; }, } as ContextMenuOptions, props.dispatch); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); }, [props.dispatch]); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied diff --git a/packages/app-desktop/gui/PromptDialog.tsx b/packages/app-desktop/gui/PromptDialog.tsx index 888c9df68d4..46fd77f4215 100644 --- a/packages/app-desktop/gui/PromptDialog.tsx +++ b/packages/app-desktop/gui/PromptDialog.tsx @@ -15,8 +15,6 @@ interface Props { defaultValue: any; visible: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - style: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied buttons: any[]; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onClose: Function; @@ -82,8 +80,8 @@ export default class PromptDialog extends React.Component { this.focusInput_ = false; } - public styles(themeId: number, width: number, height: number, visible: boolean) { - const styleKey = `${themeId}_${width}_${height}_${visible}`; + public styles(themeId: number, visible: boolean) { + const styleKey = `${themeId}_${visible}`; if (styleKey === this.styleKey_) return this.styles_; const theme = themeStyle(themeId); @@ -111,7 +109,7 @@ export default class PromptDialog extends React.Component { }; this.styles_.input = { - width: 0.5 * width, + width: 'calc(0.5 * var(--prompt-width))', maxWidth: 400, color: theme.color, backgroundColor: theme.backgroundColor, @@ -123,8 +121,8 @@ export default class PromptDialog extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied control: (provided: any) => { return { ...provided, - minWidth: width * 0.2, - maxWidth: width * 0.5, + minWidth: 'calc(var(--prompt-width) * 0.2)', + maxWidth: 'calc(var(--prompt-width) * 0.5)', fontFamily: theme.fontFamily, }; }, @@ -191,19 +189,16 @@ export default class PromptDialog extends React.Component { this.styles_.desc = { ...theme.textStyle, marginTop: 10 }; - this.styles_.dialog = { maxWidth: width }; - return this.styles_; } public render() { if (!this.state.visible) return null; - const style = this.props.style; const theme = themeStyle(this.props.themeId); const buttonTypes = this.props.buttons ? this.props.buttons : ['ok', 'cancel']; - const styles = this.styles(this.props.themeId, style.width, style.height, this.state.visible); + const styles = this.styles(this.props.themeId, this.state.visible); const onClose = (accept: boolean, buttonType: string = null) => { if (this.props.onClose) { diff --git a/packages/app-desktop/gui/Root.tsx b/packages/app-desktop/gui/Root.tsx index 948b6783f1b..ef0d5727760 100644 --- a/packages/app-desktop/gui/Root.tsx +++ b/packages/app-desktop/gui/Root.tsx @@ -1,6 +1,6 @@ import app from '../app'; import { AppState, AppStateDialog } from '../app.reducer'; -import MainScreen from './MainScreen/MainScreen'; +import MainScreen from './MainScreen'; import ConfigScreen from './ConfigScreen/ConfigScreen'; import StatusScreen from './StatusScreen/StatusScreen'; import OneDriveLoginScreen from './OneDriveLoginScreen'; @@ -19,18 +19,17 @@ import ClipperServer from '@joplin/lib/ClipperServer'; import DialogTitle from './DialogTitle'; import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow'; import Dialog from './Dialog'; -import SyncWizardDialog from './SyncWizard/Dialog'; -import MasterPasswordDialog from './MasterPasswordDialog/Dialog'; -import EditFolderDialog from './EditFolderDialog/Dialog'; -import PdfViewer from './PdfViewer'; import StyleSheetContainer from './StyleSheets/StyleSheetContainer'; import ImportScreen from './ImportScreen'; const { ResourceScreen } = require('./ResourceScreen.js'); import Navigator from './Navigator'; import WelcomeUtils from '@joplin/lib/WelcomeUtils'; import JoplinCloudLoginScreen from './JoplinCloudLoginScreen'; +import WindowCommandsAndDialogs from './WindowCommandsAndDialogs/WindowCommandsAndDialogs'; +import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer'; +import bridge from '../services/bridge'; +import EditorWindow from './NoteEditor/EditorWindow'; const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components'); -const bridge = require('@electron/remote').require('./bridge').default; interface Props { themeId: number; @@ -41,6 +40,7 @@ interface Props { zoomFactor: number; needApiAuth: boolean; dialogs: AppStateDialog[]; + secondaryWindowStates: WindowState[]; } interface ModalDialogProps { @@ -50,46 +50,6 @@ interface ModalDialogProps { onClick: ClickEventHandler; } -interface RegisteredDialogProps { - themeId: number; - key: string; - // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied - dispatch: Function; -} - -interface RegisteredDialog { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - render: (props: RegisteredDialogProps, customProps: any)=> any; -} - -const registeredDialogs: Record = { - syncWizard: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - render: (props: RegisteredDialogProps, customProps: any) => { - return ; - }, - }, - - masterPassword: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - render: (props: RegisteredDialogProps, customProps: any) => { - return ; - }, - }, - - editFolder: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - render: (props: RegisteredDialogProps, customProps: any) => { - return ; - }, - }, - pdfViewer: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - render: (props: RegisteredDialogProps, customProps: any) => { - return ; - }, - }, -}; const GlobalStyle = createGlobalStyle` * { @@ -101,7 +61,7 @@ const GlobalStyle = createGlobalStyle` let wcsTimeoutId_: any = null; async function initialize() { - bridge().window().on('resize', () => { + bridge().activeWindow().on('resize', () => { if (wcsTimeoutId_) shim.clearTimeout(wcsTimeoutId_); wcsTimeoutId_ = shim.setTimeout(() => { @@ -122,6 +82,11 @@ async function initialize() { size: bridge().windowContentSize(), }); + store.dispatch({ + type: 'EDITOR_CODE_VIEW_CHANGE', + value: Setting.value('editor.codeView'), + }); + store.dispatch({ type: 'NOTE_VISIBLE_PANES_SET', panes: Setting.value('noteVisiblePanes'), @@ -196,23 +161,14 @@ class RootComponent extends React.Component { }; } - private renderDialogs() { - const props: Props = this.props; - - if (!props.dialogs.length) return null; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const output: any[] = []; - for (const dialog of props.dialogs) { - const md = registeredDialogs[dialog.name]; - if (!md) throw new Error(`Unknown dialog: ${dialog.name}`); - output.push(md.render({ - key: dialog.name, - themeId: props.themeId, - dispatch: props.dispatch, - }, dialog.props)); - } - return output; + private renderSecondaryWindows() { + return this.props.secondaryWindowStates.map((windowState: WindowState) => { + return ; + }); } public render() { @@ -237,12 +193,13 @@ class RootComponent extends React.Component { return ( - + + + {this.renderSecondaryWindows()} {this.renderModalMessage(this.modalDialogProps())} - {this.renderDialogs()} ); @@ -258,6 +215,7 @@ const mapStateToProps = (state: AppState) => { needApiAuth: state.needApiAuth, dialogs: state.dialogs, profileConfigCurrentProfileId: state.profileConfig.currentProfileId, + secondaryWindowStates: stateUtils.secondaryWindowStates(state), }; }; diff --git a/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx b/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx index a882f6224c0..5de96ca0dac 100644 --- a/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx +++ b/packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx @@ -14,6 +14,7 @@ import useFocusHandler from './hooks/useFocusHandler'; import useOnRenderItem from './hooks/useOnRenderItem'; import { ListItem } from './types'; import useSidebarCommandHandler from './hooks/useSidebarCommandHandler'; +import { stateUtils } from '@joplin/lib/reducer'; import useOnRenderListWrapper from './hooks/useOnRenderListWrapper'; interface Props { @@ -94,15 +95,17 @@ const FolderAndTagList: React.FC = props => { }; const mapStateToProps = (state: AppState) => { + const mainWindowState = stateUtils.mainWindowState(state); + return { themeId: state.settings.theme, tags: state.tags, folders: state.folders, - notesParentType: state.notesParentType, - selectedFolderId: state.selectedFolderId, - selectedTagId: state.selectedTagId, + notesParentType: mainWindowState.notesParentType, + selectedFolderId: mainWindowState.selectedFolderId, + selectedTagId: mainWindowState.selectedTagId, collapsedFolderIds: state.collapsedFolderIds, - selectedSmartFilterId: state.selectedSmartFilterId, + selectedSmartFilterId: mainWindowState.selectedSmartFilterId, plugins: state.pluginService.plugins, tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded, folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded, diff --git a/packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.ts b/packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.ts index a8a6ef168a4..b7c529f641b 100644 --- a/packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.ts +++ b/packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.ts @@ -3,6 +3,7 @@ import { _ } from '@joplin/lib/locale'; import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp'; import { AppState } from '../../../app.reducer'; import { SidebarCommandRuntimeProps } from '../types'; +import bridge from '../../../services/bridge'; export const declaration: CommandDeclaration = { name: 'focusElementSideBar', @@ -17,6 +18,8 @@ export const runtime = (props: SidebarCommandRuntimeProps): CommandRuntime => { if (sidebarVisible) { props.focusSidebar(); + // The sidebar is only present in the main window: + bridge().switchToMainWindow(); } }, diff --git a/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx index 9dc34e96710..f9fac470e21 100644 --- a/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx +++ b/packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx @@ -118,7 +118,7 @@ const useOnRenderItem = (props: Props) => { menu.append( new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')), ); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); return; } @@ -268,7 +268,7 @@ const useOnRenderItem = (props: Props) => { } } - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); }, [props.dispatch, pluginsRef]); diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx index 2bfaf2c68cd..f9f1bd928ec 100644 --- a/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx +++ b/packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.tsx @@ -45,7 +45,7 @@ const AllNotesItem: React.FC = props => { })); } - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); }, []); return ( diff --git a/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx b/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx index 08080961ce1..a01d8a79495 100644 --- a/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx +++ b/packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.tsx @@ -40,7 +40,7 @@ const HeaderItem: React.FC = props => { new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')), ); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); } }, [itemId]); diff --git a/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx b/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx index d4d941ac0ef..f8ca4b363c6 100644 --- a/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx +++ b/packages/app-desktop/gui/StyleSheets/StyleSheetContainer.tsx @@ -8,36 +8,116 @@ // unmount is handled properly. There should only be one such component on the // page. -import { useEffect, useState } from 'react'; +import * as React from 'react'; + +import { useEffect, useMemo, useState } from 'react'; import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect'; import themeToCss from '@joplin/lib/services/style/themeToCss'; import { themeStyle } from '@joplin/lib/theme'; +import useDocument from '../hooks/useDocument'; +import { connect } from 'react-redux'; +import { AppState } from '../../app.reducer'; interface Props { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - themeId: any; + themeId: number; + editorFontSetting: string; + customChromeCssPaths: string[]; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export default function(props: Props): any { - const [styleSheetContent, setStyleSheetContent] = useState(''); +const editorFontFromSettings = (settingValue: string) => { + const fontFamilies = []; + if (settingValue) fontFamilies.push(`"${settingValue}"`); + fontFamilies.push('\'Avenir Next\', Avenir, Arial, sans-serif'); + + return fontFamilies; +}; + +const useThemeCss = (themeId: number) => { + const [themeCss, setThemeCss] = useState(''); useAsyncEffect(async (event: AsyncEffectEvent) => { - const theme = themeStyle(props.themeId); + const theme = themeStyle(themeId); const themeCss = themeToCss(theme); if (event.cancelled) return; - setStyleSheetContent(themeCss); - }, [props.themeId]); + setThemeCss(themeCss); + }, [themeId]); + + return themeCss; +}; + +const useEditorCss = (editorFontSetting: string) => { + return useMemo(() => { + const fontFamilies = editorFontFromSettings(editorFontSetting); + return ` + /* The '*' and '!important' parts are necessary to make sure Russian text is displayed properly + https://github.com/laurent22/joplin/issues/155 + + Note: Be careful about the specificity here. Incorrect specificity can break monospaced fonts in tables. */ + .CodeMirror5 *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; } + `; + }, [editorFontSetting]); +}; +const useLinkedCss = (doc: Document|null, cssPaths: string[]) => { useEffect(() => { - const element = document.createElement('style'); + if (!doc) return () => {}; + + const elements: HTMLElement[] = []; + for (const path of cssPaths) { + const element = doc.createElement('link'); + element.rel = 'stylesheet'; + element.href = path; + element.classList.add('dynamic-linked-stylesheet'); + doc.head.appendChild(element); + + elements.push(element); + } + + return () => { + for (const element of elements) { + element.remove(); + } + }; + }, [doc, cssPaths]); +}; + +const useAppliedCss = (doc: Document|null, css: string) => { + useEffect(() => { + if (!doc) return () => {}; + + const element = doc.createElement('style'); element.setAttribute('id', 'main-theme-stylesheet-container'); - document.head.appendChild(element); - element.appendChild(document.createTextNode(styleSheetContent)); + doc.head.appendChild(element); + element.appendChild(document.createTextNode(css)); return () => { - document.head.removeChild(element); + doc.head.removeChild(element); }; - }, [styleSheetContent]); + }, [css, doc]); +}; - return
; -} +const StyleSheetContainer: React.FC = props => { + const [elementRef, setElementRef] = useState(null); + const doc = useDocument(elementRef); + + const themeCss = useThemeCss(props.themeId); + const editorCss = useEditorCss(props.editorFontSetting); + + useAppliedCss(doc, ` + /* Theme CSS */ + ${themeCss} + + /* Editor font CSS */ + ${editorCss} + `); + useLinkedCss(doc, props.customChromeCssPaths); + + return
; +}; + +export default connect((state: AppState) => { + return { + themeId: state.settings.theme, + editorFontSetting: state.settings['style.editor.fontFamily'] as string, + customChromeCssPaths: state.customChromeCssPaths, + }; +})(StyleSheetContainer); diff --git a/packages/app-desktop/gui/ToolbarBase.tsx b/packages/app-desktop/gui/ToolbarBase.tsx index 0ccb4e8ac0f..83a5f3f01c9 100644 --- a/packages/app-desktop/gui/ToolbarBase.tsx +++ b/packages/app-desktop/gui/ToolbarBase.tsx @@ -106,7 +106,8 @@ const ToolbarBaseComponent: React.FC = props => { return allItems.filter(isFocusable); }, [allItems]); const containerRef = useRef(null); - const containerHasFocus = !!containerRef.current?.contains(document.activeElement); + const doc = containerRef.current?.ownerDocument; + const containerHasFocus = !!containerRef.current?.contains(doc?.activeElement); let keyCounter = 0; const renderItem = (o: ToolbarItemInfo, indexInFocusable: number) => { diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.tsx new file mode 100644 index 00000000000..11226d6883f --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { AppStateDialog } from '../../app.reducer'; +import appDialogs from './utils/appDialogs'; +import { Dispatch } from 'redux'; + +interface Props { + themeId: number; + dispatch: Dispatch; + appDialogStates: AppStateDialog[]; +} + +const AppDialogs: React.FC = props => { + if (!props.appDialogStates.length) return null; + + const output: React.ReactNode[] = []; + for (const dialog of props.appDialogStates) { + const md = appDialogs[dialog.name]; + if (!md) throw new Error(`Unknown dialog: ${dialog.name}`); + output.push(md.render({ + key: dialog.name, + themeId: props.themeId, + dispatch: props.dispatch, + }, dialog.props)); + } + return <>{output}; +}; + +export default AppDialogs; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.tsx new file mode 100644 index 00000000000..d595a8fe673 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import Dialog from '../Dialog'; + +interface Props { + message: string; +} + +const ModalMessageOverlay: React.FC = ({ message }) => { + let brIndex = 1; + const lines = message.split('\n').map((line: string) => { + if (!line.trim()) return
; + return
{line}
; + }); + + return +
+
+
+ {lines} +
+
+
; +}; + +export default ModalMessageOverlay; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.tsx new file mode 100644 index 00000000000..f8e105b9e8b --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import UserWebviewDialog from '../../services/plugins/UserWebviewDialog'; +import { PluginHtmlContents, PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer'; +import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; +import { VisibleDialogs } from '../../app.reducer'; + +interface Props { + themeId: number; + visibleDialogs: VisibleDialogs; + pluginHtmlContents: PluginHtmlContents; + plugins: PluginStates; +} + +const PluginDialogs: React.FC = props => { + const output = []; + const infos = pluginUtils.viewInfosByType(props.plugins, 'webview'); + + for (const info of infos) { + const { plugin, view } = info; + if (view.containerType !== ContainerType.Dialog) continue; + if (!props.visibleDialogs[view.id]) continue; + const html = props.pluginHtmlContents[plugin.id]?.[view.id] ?? ''; + + output.push(); + } + + if (!output.length) return null; + + return ( +
+ {output} +
+ ); +}; + +export default PluginDialogs; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx new file mode 100644 index 00000000000..f8984dfceee --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import PromptDialog from '../PromptDialog'; +import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog'; +import NotePropertiesDialog from '../NotePropertiesDialog'; +import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog'; +import ShareNoteDialog from '../ShareNoteDialog'; +import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DialogState } from './types'; +import { connect } from 'react-redux'; +import { AppState, AppStateDialog, VisibleDialogs } from '../../app.reducer'; +import { Dispatch } from 'redux'; +import ModalMessageOverlay from './ModalMessageOverlay'; +import { EditorNoteStatuses, stateUtils } from '@joplin/lib/reducer'; +import dialogs from '../dialogs'; +import useDocument from '../hooks/useDocument'; +import useWindowCommands from './utils/useWindowCommands'; +import PluginDialogs from './PluginDialogs'; +import useSyncDialogState from './utils/useSyncDialogState'; +import AppDialogs from './AppDialogs'; + +const PluginManager = require('@joplin/lib/services/PluginManager'); + +interface Props { + dispatch: Dispatch; + themeId: number; + plugins: PluginStates; + pluginHtmlContents: PluginHtmlContents; + visibleDialogs: VisibleDialogs; + appDialogStates: AppStateDialog[]; + pluginsLegacy: unknown; + modalMessage: string|null; + + customCss: string; + editorNoteStatuses: EditorNoteStatuses; +} + +const defaultDialogState: DialogState = { + noteContentPropertiesDialogOptions: { + visible: false, + }, + shareNoteDialogOptions: { + visible: false, + }, + notePropertiesDialogOptions: { + visible: false, + }, + shareFolderDialogOptions: { + visible: false, + }, + promptOptions: null, +}; + +// Certain dialog libraries need a reference to the active window: +const useSyncActiveWindow = (containerWindow: Window|null) => { + useEffect(() => { + if (!containerWindow) return () => {}; + + const onFocusCallback = () => { + dialogs.setActiveWindow(containerWindow); + }; + if (containerWindow.document.hasFocus()) { + onFocusCallback(); + } + + containerWindow.addEventListener('focus', onFocusCallback); + + return () => { + containerWindow.removeEventListener('focus', onFocusCallback); + }; + }, [containerWindow]); +}; + +const WindowCommandsAndDialogs: React.FC = props => { + const [referenceElement, setReferenceElement] = useState(null); + const containerDocument = useDocument(referenceElement); + + const documentRef = useRef(null); + documentRef.current = containerDocument; + + const [dialogState, setDialogState] = useState(defaultDialogState); + + useSyncDialogState(dialogState, props.dispatch); + useWindowCommands({ + documentRef, + customCss: props.customCss, + plugins: props.plugins, + editorNoteStatuses: props.editorNoteStatuses, + setDialogState, + }); + useSyncActiveWindow(containerDocument?.defaultView); + + const onDialogHideCallbacks = useMemo(() => { + type OnHideCallbacks = Partial void>>; + const result: OnHideCallbacks = {}; + for (const key of Object.keys(defaultDialogState)) { + result[key as keyof DialogState] = () => { + setDialogState(dialogState => { + return { + ...dialogState, + [key]: { visible: false }, + }; + }); + }; + } + return result; + }, []); + + const promptOnClose = useCallback((answer: unknown, buttonType: unknown) => { + dialogState.promptOptions.onClose(answer, buttonType); + }, [dialogState.promptOptions]); + + const dialogInfo = PluginManager.instance().pluginDialogToShow(props.pluginsLegacy); + const pluginDialog = !dialogInfo ? null : ; + + const { noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions } = dialogState; + + + return <> +
+ {pluginDialog} + {props.modalMessage !== null ? : null} + + + {noteContentPropertiesDialogOptions.visible && ( + + )} + {notePropertiesDialogOptions.visible && ( + + )} + {shareNoteDialogOptions.visible && ( + + )} + {shareFolderDialogOptions.visible && ( + + )} + + + ; +}; + +interface ConnectProps { + windowId: string; +} + +export default connect((state: AppState, ownProps: ConnectProps) => { + const windowState = stateUtils.windowStateById(state, ownProps.windowId); + + return { + themeId: state.settings.theme, + plugins: state.pluginService.plugins, + visibleDialogs: windowState.visibleDialogs, + appDialogStates: windowState.dialogs, + pluginHtmlContents: state.pluginService.pluginHtmlContents, + customCss: state.customViewerCss, + editorNoteStatuses: state.editorNoteStatuses, + pluginsLegacy: state.pluginsLegacy, + modalMessage: state.modalOverlayMessage, + }; +})(WindowCommandsAndDialogs); diff --git a/packages/app-desktop/gui/MainScreen/commands/addProfile.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/addProfile.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/commandPalette.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/commandPalette.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/deleteFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/duplicateNote.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/duplicateNote.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/editAlarm.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/editAlarm.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/exportPdf.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/exportPdf.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/gotoAnything.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/gotoAnything.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.ts diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.ts new file mode 100644 index 00000000000..26047820e3e --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.ts @@ -0,0 +1,13 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; + +export const declaration: CommandDeclaration = { + name: 'hideModalMessage', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext) => { + context.dispatch({ type: 'HIDE_MODAL_MESSAGE' }); + }, + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/index.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/index.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/moveToFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/moveToFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/newFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/newFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/newNote.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/newNote.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/newSubFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/newTodo.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/newTodo.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openFolderDialog.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openFolderDialog.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openItem.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openItem.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openNote.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openNote.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openPdfViewer.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/openTag.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/openTag.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/print.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.ts similarity index 76% rename from packages/app-desktop/gui/MainScreen/commands/print.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.ts index 3940c1cb64f..06204f00fee 100644 --- a/packages/app-desktop/gui/MainScreen/commands/print.ts +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.ts @@ -1,5 +1,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import { _ } from '@joplin/lib/locale'; +import { WindowControl } from '../utils/useWindowControl'; const bridge = require('@electron/remote').require('./bridge').default; export const declaration: CommandDeclaration = { @@ -8,15 +9,14 @@ export const declaration: CommandDeclaration = { iconName: 'fa-file', }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export const runtime = (comp: any): CommandRuntime => { +export const runtime = (comp: WindowControl): CommandRuntime => { return { execute: async (context: CommandContext, noteIds: string[] = null) => { noteIds = noteIds || context.state.selectedNoteIds; try { if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.')); - await comp.printTo_('printer', { noteId: noteIds[0] }); + await comp.printTo('printer', { noteId: noteIds[0] }); } catch (error) { bridge().showErrorMessageBox(error.message); } diff --git a/packages/app-desktop/gui/MainScreen/commands/renameFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/renameFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/renameTag.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/renameTag.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/resetLayout.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/resetLayout.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/restoreFolder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/restoreNote.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/restoreNote.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/revealResourceFile.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/revealResourceFile.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/search.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/search.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/setTags.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/setTags.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.ts diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.ts new file mode 100644 index 00000000000..c17dfea4f0a --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.ts @@ -0,0 +1,16 @@ +import { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService'; + +export const declaration: CommandDeclaration = { + name: 'showModalMessage', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, message: string) => { + context.dispatch({ + type: 'SHOW_MODAL_MESSAGE', + message, + }); + }, + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showNoteContentProperties.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showNoteProperties.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showNoteProperties.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showPrompt.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showPrompt.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.test.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.test.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.ts similarity index 97% rename from packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.ts index 73476bf687c..05f7a309255 100644 --- a/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.ts @@ -21,7 +21,7 @@ export const runtime = (): CommandRuntime => { const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguages, useSpellChecker); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const menu = Menu.buildFromTemplate(menuItems as any); - menu.popup({ window: bridge().window() }); + menu.popup({ window: bridge().activeWindow() }); }, mapStateToTitle(state: AppState): string { diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleEditors.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.ts similarity index 82% rename from packages/app-desktop/gui/MainScreen/commands/toggleEditors.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.ts index 60437dc9767..4a0160cd1b4 100644 --- a/packages/app-desktop/gui/MainScreen/commands/toggleEditors.ts +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.ts @@ -1,7 +1,7 @@ import { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService'; -import Setting from '@joplin/lib/models/Setting'; import { stateUtils } from '@joplin/lib/reducer'; import { _ } from '@joplin/lib/locale'; +import Setting from '@joplin/lib/models/Setting'; export const declaration: CommandDeclaration = { name: 'toggleEditors', @@ -17,7 +17,10 @@ export const runtime = (): CommandRuntime => { // TinyMCE because it won't have time to send its content before // being switch to Ace Editor. if (stateUtils.hasNotesBeingSaved(context.state)) return; - Setting.toggle('editor.codeView'); + + const newValue = !Setting.value('editor.codeView'); + Setting.setValue('editor.codeView', newValue); + context.dispatch({ type: 'EDITOR_CODE_VIEW_CHANGE', value: newValue }); }, enabledCondition: '!notesAreBeingSaved && oneNoteSelected', }; diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleMenuBar.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleMenuBar.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNoteList.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteList.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleNoteList.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteList.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNoteType.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteType.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleNoteType.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNoteType.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderField.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderField.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderReverse.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderReverse.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleSideBar.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.ts similarity index 100% rename from packages/app-desktop/gui/MainScreen/commands/toggleSideBar.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.ts diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.ts similarity index 52% rename from packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.ts rename to packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.ts index 3af6b005196..829f7fb7280 100644 --- a/packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.ts +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.ts @@ -1,4 +1,4 @@ -import { CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; import { _ } from '@joplin/lib/locale'; export const declaration: CommandDeclaration = { @@ -7,11 +7,10 @@ export const declaration: CommandDeclaration = { iconName: 'icon-layout ', }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export const runtime = (comp: any): CommandRuntime => { +export const runtime = (): CommandRuntime => { return { - execute: async () => { - comp.props.dispatch({ + execute: async (context: CommandContext) => { + context.dispatch({ type: 'NOTE_VISIBLE_PANES_TOGGLE', }); }, diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/types.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/types.ts new file mode 100644 index 00000000000..c78650f5169 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/types.ts @@ -0,0 +1,31 @@ +import { MarkupLanguage } from '@joplin/renderer'; + +export interface DialogState { + noteContentPropertiesDialogOptions: { + visible: boolean; + noteId?: string; + text?: string; + markupLanguage?: MarkupLanguage; + }; + shareNoteDialogOptions: { + visible: boolean; + noteIds?: string[]; + }; + notePropertiesDialogOptions: { + visible: boolean; + noteId?: string; + onRevisionLinkClick?: ()=> void; + }; + shareFolderDialogOptions: { + visible: boolean; + folderId?: string; + }; + promptOptions: { + inputType?: string; + buttons?: unknown[]; + description?: string; + label?: string; + value?: string; + onClose?: (answer: unknown, buttonType: unknown)=> void; + }|null; +} diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx new file mode 100644 index 00000000000..07a17fcdf45 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import SyncWizardDialog from '../../SyncWizard/Dialog'; +import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog'; +import EditFolderDialog from '../../EditFolderDialog/Dialog'; +import PdfViewer from '../../PdfViewer'; + +interface RegisteredDialogProps { + themeId: number; + key: string; + // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied + dispatch: Function; +} + +interface RegisteredDialog { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + render: (props: RegisteredDialogProps, customProps: any)=> any; +} + +const appDialogs: Record = { + syncWizard: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + render: (props: RegisteredDialogProps, customProps: any) => { + return ; + }, + }, + + masterPassword: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + render: (props: RegisteredDialogProps, customProps: any) => { + return ; + }, + }, + + editFolder: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + render: (props: RegisteredDialogProps, customProps: any) => { + return ; + }, + }, + pdfViewer: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + render: (props: RegisteredDialogProps, customProps: any) => { + return ; + }, + }, +}; + +export default appDialogs; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.ts new file mode 100644 index 00000000000..ff1a2cd63b0 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.ts @@ -0,0 +1,78 @@ +import { useCallback, useRef } from 'react'; +import bridge from '../../../services/bridge'; +import InteropServiceHelper from '../../../InteropServiceHelper'; +import Setting from '@joplin/lib/models/Setting'; +import shim from '@joplin/lib/shim'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { msleep } from '@joplin/utils/time'; + +let isPrinting = false; + +interface Props { + customCss: string; + plugins: PluginStates; + editorNoteStatuses: Record; +} + +interface PrintOptions { + path?: string; + noteId: string; +} + +export type PrintCallback = (target: string, options: PrintOptions)=> Promise; + +const usePrintToCallback = (props: Props): PrintCallback => { + const noteStatusesRef = useRef(props.editorNoteStatuses); + noteStatusesRef.current = props.editorNoteStatuses; + + const waitForNoteToSaved = useCallback(async (noteId: string) => { + while (noteId && noteStatusesRef.current[noteId] === 'saving') { + // eslint-disable-next-line no-console -- Old code from before rule was applied + console.info('Waiting for note to be saved...', noteStatusesRef.current); + await msleep(100); + } + }, []); + + return useCallback(async (target, options) => { + // Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion + if (isPrinting) { + // eslint-disable-next-line no-console -- Old code from before rule was applied + console.info(`Printing ${options.path ?? options.noteId} to ${target} disallowed, already printing.`); + return; + } + + isPrinting = true; + + // Need to wait for save because the interop service reloads the note from the database + await waitForNoteToSaved(options.noteId); + + if (target === 'pdf') { + try { + const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, { + printBackground: true, + pageSize: Setting.value('export.pdfPageSize'), + landscape: Setting.value('export.pdfPageOrientation') === 'landscape', + customCss: props.customCss, + plugins: props.plugins, + }); + await shim.fsDriver().writeFile(options.path, pdfData, 'buffer'); + } catch (error) { + console.error(error); + bridge().showErrorMessageBox(error.message); + } + } else if (target === 'printer') { + try { + await InteropServiceHelper.printNote(options.noteId, { + printBackground: true, + customCss: props.customCss, + }); + } catch (error) { + console.error(error); + bridge().showErrorMessageBox(error.message); + } + } + isPrinting = false; + }, [props.plugins, waitForNoteToSaved, props.customCss]); +}; + +export default usePrintToCallback; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.ts new file mode 100644 index 00000000000..69f6d6ab332 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { DialogState } from '../types'; +import { Dispatch } from 'redux'; + +// Syncs whether dialogs are open/closed with the global reducer state. +const useSyncDialogState = (dialogState: DialogState, dispatch: Dispatch) => { + const lastDialogStateRef = useRef(dialogState); + useEffect(() => { + const prevState = lastDialogStateRef.current; + const state = dialogState; + if (state.notePropertiesDialogOptions !== prevState.notePropertiesDialogOptions) { + dispatch({ + type: state.notePropertiesDialogOptions && state.notePropertiesDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', + name: 'noteProperties', + }); + } + + if (state.noteContentPropertiesDialogOptions !== prevState.noteContentPropertiesDialogOptions) { + dispatch({ + type: state.noteContentPropertiesDialogOptions && state.noteContentPropertiesDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', + name: 'noteContentProperties', + }); + } + + if (state.shareNoteDialogOptions !== prevState.shareNoteDialogOptions) { + dispatch({ + type: state.shareNoteDialogOptions && state.shareNoteDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', + name: 'shareNote', + }); + } + + if (state.shareFolderDialogOptions !== prevState.shareFolderDialogOptions) { + dispatch({ + type: state.shareFolderDialogOptions && state.shareFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE', + name: 'shareFolder', + }); + } + lastDialogStateRef.current = dialogState; + }, [dialogState, dispatch]); +}; + +export default useSyncDialogState; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.ts new file mode 100644 index 00000000000..595aa749778 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.ts @@ -0,0 +1,51 @@ +import { EditorNoteStatuses } from '@joplin/lib/reducer'; +import { RefObject } from 'react'; +import usePrintToCallback from './usePrintToCallback'; +import useWindowControl, { OnSetDialogState, WindowControl } from './useWindowControl'; +import commands from '../commands'; +import CommandService, { CommandRuntime, ComponentCommandSpec } from '@joplin/lib/services/CommandService'; +import useNowEffect from '@joplin/lib/hooks/useNowEffect'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; + +interface Props { + documentRef: RefObject; + customCss: string; + plugins: PluginStates; + editorNoteStatuses: EditorNoteStatuses; + setDialogState: OnSetDialogState; +} + +const useWindowCommands = ({ documentRef, customCss, plugins, editorNoteStatuses, setDialogState }: Props) => { + const onPrintCallback = usePrintToCallback({ + customCss: customCss, + editorNoteStatuses: editorNoteStatuses, + plugins: plugins, + }); + const windowControl = useWindowControl(setDialogState, onPrintCallback); + + // This effect needs to run as soon as possible. Certain components may fail to load if window + // commands are not registered on their first render. + useNowEffect(() => { + const runtimeHandles = commands.map((command: ComponentCommandSpec) => { + const runtime: CommandRuntime = { + getPriority: () => { + return documentRef.current?.hasFocus() ? 1 : 0; + }, + ...command.runtime(windowControl), + }; + return CommandService.instance().registerRuntime( + command.declaration.name, + runtime, + true, + ); + }); + + return () => { + for (const runtimeHandle of runtimeHandles) { + runtimeHandle.deregister(); + } + }; + }, [windowControl]); +}; + +export default useWindowCommands; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.ts new file mode 100644 index 00000000000..b5b50ba2269 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { useMemo, useRef } from 'react'; +import { DialogState } from '../types'; +import { PrintCallback } from './usePrintToCallback'; + +export interface WindowControl { + setState: (update: Partial)=> void; + printTo: PrintCallback; +} + +export type OnSetDialogState = React.Dispatch>; +const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallback) => { + // Use refs to avoid reloading the output where possible -- reloading the window control + // may mean reloading all main window commands. + const onPrintRef = useRef(onPrint); + onPrintRef.current = onPrint; + + return useMemo((): WindowControl => { + return { + setState: (newPartialState: Partial) => { + setDialogState(oldState => ({ + ...oldState, + ...newPartialState, + })); + }, + printTo: (target, options) => onPrintRef.current(target, options), + }; + }, [setDialogState]); +}; + +export default useWindowControl; diff --git a/packages/app-desktop/gui/dialogs.ts b/packages/app-desktop/gui/dialogs.ts index b05f55eebfb..4a57285759a 100644 --- a/packages/app-desktop/gui/dialogs.ts +++ b/packages/app-desktop/gui/dialogs.ts @@ -1,20 +1,42 @@ import Logger from '@joplin/utils/Logger'; -// Can't upgrade beyond 2.x because it doesn't work with Electron. If trying to -// upgrade again, check that adding a link from the CodeMirror editor works/ -const smalltalk = require('smalltalk'); - const logger = Logger.create('dialogs'); +interface Smalltalk { + alert(title: string, message: string): Promise; + confirm(title: string, message: string, options: unknown): Promise; + prompt(title: string, message: string, defaultValue: string, options: unknown): Promise; +} + class Dialogs { + private activeWindow: Window|null; + public setActiveWindow(win: Window) { + this.activeWindow = win; + } + + private get smalltalk(): Smalltalk { + // The smalltalk library shows prompts in whichever document it is loaded in + // (it uses the global document object). + // As such, a copy of the library needs to be loaded in each window: + if (this.activeWindow && 'smalltalk' in this.activeWindow) { + return this.activeWindow.smalltalk as Smalltalk; + } + + if ('smalltalk' in window) { + return window.smalltalk as Smalltalk; + } + + throw new Error('Unable to find the smalltalk library. Please make sure it has been loaded with a - -
+ - - diff --git a/packages/app-desktop/integration-tests/models/MainScreen.ts b/packages/app-desktop/integration-tests/models/MainScreen.ts index 414a2982cdf..6d89f3de66b 100644 --- a/packages/app-desktop/integration-tests/models/MainScreen.ts +++ b/packages/app-desktop/integration-tests/models/MainScreen.ts @@ -5,6 +5,7 @@ import Sidebar from './Sidebar'; import GoToAnything from './GoToAnything'; import setFilePickerResponse from '../util/setFilePickerResponse'; import NoteList from './NoteList'; +import { expect } from '../util/test'; export default class MainScreen { public readonly newNoteButton: Locator; @@ -31,11 +32,10 @@ export default class MainScreen { // Follows the steps a user would use to create a new note. public async createNewNote(title: string) { await this.waitFor(); - await this.newNoteButton.click(); - await this.noteEditor.waitFor(); - // Wait for the title input to have the correct placeholder - await this.page.locator('input[placeholder^="Creating new note"]').waitFor(); + await this.newNoteButton.click(); + await expect(this.noteEditor.noteTitleInput).toHaveValue(''); + await expect(this.noteEditor.noteTitleInput).toHaveJSProperty('placeholder', 'Creating new note...'); // Fill the title await this.noteEditor.noteTitleInput.click(); diff --git a/packages/app-desktop/integration-tests/util/activateMainMenuItem.ts b/packages/app-desktop/integration-tests/util/activateMainMenuItem.ts index 56388863244..cd5d249b791 100644 --- a/packages/app-desktop/integration-tests/util/activateMainMenuItem.ts +++ b/packages/app-desktop/integration-tests/util/activateMainMenuItem.ts @@ -41,7 +41,7 @@ const activateMainMenuItem = async ( }; const appMenu = Menu.getApplicationMenu(); - return activateItemInSubmenu(appMenu.items, ''); + return !!appMenu && activateItemInSubmenu(appMenu.items, ''); }, [targetItemLabel, parentMenuLabel]); }, { message: `should find and activate menu item with label ${JSON.stringify(targetItemLabel)}`, diff --git a/packages/app-desktop/main-html.js b/packages/app-desktop/main-html.js index 734d4600e3d..982cc7e02a2 100644 --- a/packages/app-desktop/main-html.js +++ b/packages/app-desktop/main-html.js @@ -112,27 +112,6 @@ const main = async () => { pdfJs, }); - // Disable drag and drop of links inside application (which would - // open it as if the whole app was a browser) - document.addEventListener('dragover', event => event.preventDefault()); - document.addEventListener('drop', event => event.preventDefault()); - - // Disable middle-click (which would open a new browser window, but we don't want this) - document.addEventListener('auxclick', event => event.preventDefault()); - - // Each link (rendered as a button or list item) has its own custom click event - // so disable the default. In particular this will disable Ctrl+Clicking a link - // which would open a new browser window. - document.addEventListener('click', (event) => { - // We don't apply this to labels and inputs because it would break - // checkboxes. Such a global event handler is probably not a good idea - // anyway but keeping it for now, as it doesn't seem to break anything else. - // https://github.com/facebook/react/issues/13477#issuecomment-489274045 - if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return; - - event.preventDefault(); - }); - const logger = new Logger(); Logger.initializeGlobalLogger(logger); initLib(logger); diff --git a/packages/app-desktop/main.scss b/packages/app-desktop/main.scss index c862447e5fb..399fede1c44 100644 --- a/packages/app-desktop/main.scss +++ b/packages/app-desktop/main.scss @@ -4,6 +4,10 @@ body, textarea { border: none; } +:root { + background-color: var(--joplin-background-color); +} + #react-root { height: 100%; overflow: hidden; @@ -295,3 +299,37 @@ Component-specific classes // flex: 1; // margin-right: 10px; // } + + +// Disable dragging of links (which are often buttons) +a:not([draggable=true]), img:not([draggable=true]) { + -webkit-user-drag: none; +} + +.smalltalk { + background-color: rgba(0,0,0,.5); +} +.smalltalk input { + margin-top: 1em; +} +.smalltalk .page { + max-width: 30em; +} +mark { + background: #CF3F00; + color: white; +} + +/* + Adds support for RTL text in the note body. It automatically detects the direction using the content. + Issue: https://github.com/laurent22/joplin/issues/3991 +*/ +.CodeMirror-line { + unicode-bidi: plaintext; +} + +// Legacy CodeMirror editor overrides +.CodeMirror5 > .CodeMirror { + height: 100%; + width: 100%; +} diff --git a/packages/app-desktop/plugins/GotoAnything.tsx b/packages/app-desktop/plugins/GotoAnything.tsx index b406623f023..f450f6c1495 100644 --- a/packages/app-desktop/plugins/GotoAnything.tsx +++ b/packages/app-desktop/plugins/GotoAnything.tsx @@ -636,7 +636,9 @@ class DialogComponent extends React.PureComponent { } private calculateMaxHeight(itemHeight: number) { - const maxItemCount = Math.floor((0.7 * window.innerHeight) / itemHeight); + const listContainer: HTMLElement|null = this.itemListRef.current?.container; + const containerWindow = listContainer?.ownerDocument?.defaultView ?? window; + const maxItemCount = Math.floor((0.7 * containerWindow.innerHeight) / itemHeight); return maxItemCount * itemHeight; } diff --git a/packages/app-desktop/services/commands/stateToWhenClauseContext.ts b/packages/app-desktop/services/commands/stateToWhenClauseContext.ts index c35c6b34859..761faaa859c 100644 --- a/packages/app-desktop/services/commands/stateToWhenClauseContext.ts +++ b/packages/app-desktop/services/commands/stateToWhenClauseContext.ts @@ -6,21 +6,26 @@ import { AppState } from '../../app.reducer'; import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/lib/services/commands/stateToWhenClauseContext'; import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp'; +import { defaultWindowId, stateUtils } from '@joplin/lib/reducer'; export default function stateToWhenClauseContext(state: AppState, options: WhenClauseContextOptions = null) { + const windowId = options?.windowId ?? defaultWindowId; + const isMainWindow = windowId === defaultWindowId; + const windowState = stateUtils.windowStateById(state, windowId); + return { ...libStateToWhenClauseContext(state, options), // UI elements - markdownEditorVisible: !!state.settings['editor.codeView'] && !state.settings['isSafeMode'], - richTextEditorVisible: !state.settings['editor.codeView'] && !state.settings['isSafeMode'], + markdownEditorVisible: !!windowState.editorCodeView && !state.settings['isSafeMode'], + richTextEditorVisible: !windowState.editorCodeView && !state.settings['isSafeMode'], - markdownEditorPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('editor'), - markdownViewerPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('viewer'), + markdownEditorPaneVisible: windowState.editorCodeView && windowState.noteVisiblePanes.includes('editor'), + markdownViewerPaneVisible: windowState.editorCodeView && windowState.noteVisiblePanes.includes('viewer'), modalDialogVisible: !!Object.keys(state.visibleDialogs).length, gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'], - sidebarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'), - noteListHasNotes: !!state.notes.length, + sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'), + noteListHasNotes: !!windowState.notes.length, // Deprecated sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'), diff --git a/packages/app-desktop/services/electron-context-menu.js b/packages/app-desktop/services/electron-context-menu.js index 61a1e4fff6e..e8cb686e692 100644 --- a/packages/app-desktop/services/electron-context-menu.js +++ b/packages/app-desktop/services/electron-context-menu.js @@ -238,7 +238,9 @@ const create = (win, options) => { // When this is being called from a web view, we can't use `win` as this // would refer to the web view which is not allowed to render a popup menu. // - menu.popup({ window: electronRemote ? electronRemote.getCurrentWindow() : win }); + // Joplin change: Do not use electronRemote to get the current window -- this causes + // the menu to be shown on the wrong window in MacOS. + menu.popup({ window: win }); } }); }; diff --git a/packages/app-desktop/services/plugins/PlatformImplementation.ts b/packages/app-desktop/services/plugins/PlatformImplementation.ts index 8e1e792e8e9..198685479b0 100644 --- a/packages/app-desktop/services/plugins/PlatformImplementation.ts +++ b/packages/app-desktop/services/plugins/PlatformImplementation.ts @@ -1,6 +1,4 @@ import bridge from '../bridge'; -import { Implementation as WindowImplementation } from '@joplin/lib/services/plugins/api/JoplinWindow'; -import { injectCustomStyles } from '@joplin/lib/CssUtils'; import { VersionInfo } from '@joplin/lib/services/plugins/api/types'; import Setting from '@joplin/lib/models/Setting'; import { reg } from '@joplin/lib/registry'; @@ -50,12 +48,6 @@ export default class PlatformImplementation extends BasePlatformImplementation { return nativeImage; } - public get window(): WindowImplementation { - return { - injectCustomStyles: injectCustomStyles, - }; - } - public constructor() { super(); diff --git a/packages/app-desktop/services/plugins/UserWebview.tsx b/packages/app-desktop/services/plugins/UserWebview.tsx index fd41426093c..a9ed3d5b9cc 100644 --- a/packages/app-desktop/services/plugins/UserWebview.tsx +++ b/packages/app-desktop/services/plugins/UserWebview.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo } from 'react'; +import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext } from 'react'; import useViewIsReady from './hooks/useViewIsReady'; import useThemeCss from './hooks/useThemeCss'; import useContentSize from './hooks/useContentSize'; @@ -9,6 +9,7 @@ import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages'; import useScriptLoader from './hooks/useScriptLoader'; import Logger from '@joplin/utils/Logger'; import { focus } from '@joplin/lib/utils/focusHandler'; +import { WindowIdContext } from '../../gui/NewWindowOrIFrame'; const logger = Logger.create('UserWebview'); @@ -125,11 +126,13 @@ function UserWebview(props: Props, ref: any) { htmlHash, ); + const windowId = useContext(WindowIdContext); useWebviewToPluginMessages( frameWindow(), isReady, props.pluginId, props.viewId, + windowId, postMessage, ); diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts index 3ebfb3c0621..aacf8758f5e 100644 --- a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts +++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts @@ -2,14 +2,14 @@ import PostMessageService, { MessageResponse, ResponderComponentType } from '@jo import { useEffect } from 'react'; // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied -export default function(frameWindow: any, isReady: boolean, pluginId: string, viewId: string, postMessage: Function) { +export default function(frameWindow: any, isReady: boolean, pluginId: string, viewId: string, windowId: string, postMessage: Function) { useEffect(() => { - PostMessageService.instance().registerResponder(ResponderComponentType.UserWebview, viewId, (message: MessageResponse) => { + PostMessageService.instance().registerResponder(ResponderComponentType.UserWebview, viewId, windowId, (message: MessageResponse) => { postMessage('postMessageService.response', { message }); }); return () => { - PostMessageService.instance().unregisterResponder(ResponderComponentType.UserWebview, viewId); + PostMessageService.instance().unregisterResponder(ResponderComponentType.UserWebview, viewId, windowId); }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [viewId]); @@ -32,6 +32,7 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi void PostMessageService.instance().postMessage({ pluginId, viewId, + windowId, ...event.data.message, }); } @@ -43,5 +44,5 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi if (frameWindow?.removeEventListener) frameWindow.removeEventListener('message', onMessage_); }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [frameWindow, isReady, pluginId, viewId]); + }, [frameWindow, isReady, pluginId, windowId, viewId]); } diff --git a/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts b/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts index ce933fef2ba..e9cdae75870 100644 --- a/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts +++ b/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts @@ -11,7 +11,7 @@ export default class SpellCheckerServiceDriverNative extends SpellCheckerService // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private session(): any { - return bridge().window().webContents.session; + return bridge().mainWindow().webContents.session; } public get availableLanguages(): string[] { diff --git a/packages/app-desktop/style.scss b/packages/app-desktop/style.scss index e6234dc2afd..259d6e0ff55 100644 --- a/packages/app-desktop/style.scss +++ b/packages/app-desktop/style.scss @@ -16,3 +16,15 @@ @use 'services/plugins/styles/index.scss' as plugins-styles; @use 'gui/styles/index.scss' as gui-styles; @use 'main.scss' as main; + +// These dynamic imports will be compiled to CSS imports in the output file. +// Prefer importing new CSS files here to linking them in index.html because +// this allows the CSS files to be included in secondary windows. +// +// See https://sasscss.org/documentation/at-rules/import#plain-css-imports. +@import url('style/icons/style.css'); +@import url('vendor/lib/@fortawesome/fontawesome-free/css/all.min.css'); +@import url('vendor/lib/react-datetime/css/react-datetime.css'); +@import url('vendor/lib/smalltalk/css/smalltalk.css'); +@import url('vendor/lib/roboto-fontface/css/roboto/roboto-fontface.css'); +@import url('vendor/lib/codemirror/lib/codemirror.css'); diff --git a/packages/app-desktop/tools/copyApplicationAssets.js b/packages/app-desktop/tools/copyApplicationAssets.js index 5049ad01366..a6c7bb5ffae 100644 --- a/packages/app-desktop/tools/copyApplicationAssets.js +++ b/packages/app-desktop/tools/copyApplicationAssets.js @@ -88,6 +88,7 @@ async function main() { 'react-datetime/css/react-datetime.css', 'roboto-fontface/css/roboto/roboto-fontface.css', 'smalltalk/css/smalltalk.css', + 'smalltalk/dist/smalltalk.min.js', 'smalltalk/img/IDR_CLOSE_DIALOG_H.png', 'smalltalk/img/IDR_CLOSE_DIALOG.png', { diff --git a/packages/app-desktop/utils/window/eventHandlerOverrides.js b/packages/app-desktop/utils/window/eventHandlerOverrides.js new file mode 100644 index 00000000000..ac12336fecd --- /dev/null +++ b/packages/app-desktop/utils/window/eventHandlerOverrides.js @@ -0,0 +1,23 @@ +// TODO: These are already partially replaced by Electron main-process +// event handlers. In the future, these may be possible to remove. + +// Disable drag and drop of links inside application (which would +// open it as if the whole app was a browser) +document.addEventListener('dragover', event => event.preventDefault()); +document.addEventListener('drop', event => event.preventDefault()); + +// Disable middle-click (which would open a new browser window, but we don't want this) +document.addEventListener('auxclick', event => event.preventDefault()); + +// Each link (rendered as a button or list item) has its own custom click event +// so disable the default. In particular this will disable Ctrl+Clicking a link +// which would open a new browser window. +document.addEventListener('click', (event) => { + // We don't apply this to labels and inputs because it would break + // checkboxes. Such a global event handler is probably not a good idea + // anyway but keeping it for now, as it doesn't seem to break anything else. + // https://github.com/facebook/react/issues/13477#issuecomment-489274045 + if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return; + + event.preventDefault(); +}); diff --git a/packages/app-desktop/utils/window/secondaryWindowPreload.js b/packages/app-desktop/utils/window/secondaryWindowPreload.js new file mode 100644 index 00000000000..866ffd4cc97 --- /dev/null +++ b/packages/app-desktop/utils/window/secondaryWindowPreload.js @@ -0,0 +1,5 @@ +const { ipcRenderer } = require('electron'); + +window.electronWindow = { + onSetWindowId: windowId => ipcRenderer.send('secondary-window-added', windowId), +}; diff --git a/packages/app-desktop/utils/window/types.ts b/packages/app-desktop/utils/window/types.ts new file mode 100644 index 00000000000..045b2829394 --- /dev/null +++ b/packages/app-desktop/utils/window/types.ts @@ -0,0 +1,4 @@ + +export interface SecondaryWindowApi { + onSetWindowId(windowId: string): void; +} diff --git a/packages/app-mobile/services/plugins/PlatformImplementation.ts b/packages/app-mobile/services/plugins/PlatformImplementation.ts index c5805255ac7..ea90783371b 100644 --- a/packages/app-mobile/services/plugins/PlatformImplementation.ts +++ b/packages/app-mobile/services/plugins/PlatformImplementation.ts @@ -1,5 +1,4 @@ import { VersionInfo } from '@joplin/lib/services/plugins/api/types'; -import { Implementation as WindowImplementation } from '@joplin/lib/services/plugins/api/JoplinWindow'; import Setting from '@joplin/lib/models/Setting'; import { reg } from '@joplin/lib/registry'; import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation'; @@ -112,10 +111,4 @@ export default class PlatformImplementation extends BasePlatformImplementation { availableFormats: () => ['text/plain'], }; } - - public get window(): WindowImplementation { - return { - injectCustomStyles: null, - }; - } } diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 2b830f332ea..2d9f07a80aa 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -11,7 +11,7 @@ import KvStore from './services/KvStore'; import SyncTargetJoplinServer from './SyncTargetJoplinServer'; import SyncTargetOneDrive from './SyncTargetOneDrive'; import { createStore, applyMiddleware, Store } from 'redux'; -const { defaultState, stateUtils } = require('./reducer'); +import { defaultState, stateUtils } from './reducer'; import JoplinDatabase from './JoplinDatabase'; import { cancelTimers as folderScreenUtilsCancelTimers, refreshFolders, scheduleRefreshFolders } from './folders-screen-utils'; const { DatabaseDriverNode } = require('./database-driver-node.js'); @@ -64,6 +64,7 @@ import { join } from 'path'; import processStartFlags from './utils/processStartFlags'; import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems'; import determineProfileAndBaseDir from './determineBaseAppDirs'; +import NavService from './services/NavService'; const appLogger: LoggerWrapper = Logger.create('App'); @@ -98,8 +99,7 @@ export default class BaseApplication { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied protected currentFolder_: any = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - protected store_: Store = null; + protected store_: Store = null; private rotatingLogs: RotatingLogs; @@ -511,6 +511,14 @@ export default class BaseApplication { refreshNotesUseSelectedNoteId = true; } + // Switching windows can also change which note(s) and which note parent type is selected. + // Refreshing notes after switching windows helps ensure that the selected note/tags/other state + // is correct for the current window. + if (action.type === 'WINDOW_FOCUS' && action.lastWindowId !== action.windowId) { + refreshNotes = true; + refreshNotesUseSelectedNoteId = true; + } + // Should refresh the notes when: // - A tag is selected, to show the notes for that tag // - When a tag is updated so that when searching by tags, the search results are updated @@ -610,6 +618,7 @@ export default class BaseApplication { BaseModel.dispatch = this.store().dispatch; BaseSyncTarget.dispatch = this.store().dispatch; + NavService.dispatch = this.store().dispatch; DecryptionWorker.instance().dispatch = this.store().dispatch; ResourceFetcher.instance().dispatch = this.store().dispatch; ShareService.instance().initialize(this.store(), EncryptionService.instance()); diff --git a/packages/lib/eventManager.ts b/packages/lib/eventManager.ts index 60843d3e129..de762e88222 100644 --- a/packages/lib/eventManager.ts +++ b/packages/lib/eventManager.ts @@ -24,6 +24,10 @@ export enum EventName { interface ItemChangeEvent { itemType: ModelType; itemId: string; + // Passing a changeId to Note.save causes that changeId to be included + // in the corresponding ItemChangeEvent. This allows determining which + // call to Note.save triggered the event. + changeId: string; eventType: number; } diff --git a/packages/lib/hooks/useNowEffect.test.ts b/packages/lib/hooks/useNowEffect.test.ts new file mode 100644 index 00000000000..3a43fd9b82f --- /dev/null +++ b/packages/lib/hooks/useNowEffect.test.ts @@ -0,0 +1,29 @@ +import type * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useNowEffect from './useNowEffect'; + +describe('useNowEffect', () => { + test('should call the cleanup callback when the effect function is called and after unmount', async () => { + const cleanupFunction = jest.fn(() => {}); + const effectFunction = jest.fn(() => cleanupFunction); + const useTestHook = (dependencies: React.DependencyList) => { + return useNowEffect(effectFunction, dependencies); + }; + + const hook = renderHook(useTestHook, { initialProps: [0] }); + expect(cleanupFunction).not.toHaveBeenCalled(); + expect(effectFunction).toHaveBeenCalledTimes(1); + + hook.rerender([0]); + expect(cleanupFunction).not.toHaveBeenCalled(); + expect(effectFunction).toHaveBeenCalledTimes(1); + + hook.rerender([1]); + expect(cleanupFunction).toHaveBeenCalledTimes(1); + expect(effectFunction).toHaveBeenCalledTimes(2); + + hook.unmount(); + expect(cleanupFunction).toHaveBeenCalledTimes(2); + expect(effectFunction).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/lib/hooks/useNowEffect.ts b/packages/lib/hooks/useNowEffect.ts new file mode 100644 index 00000000000..c6752287792 --- /dev/null +++ b/packages/lib/hooks/useNowEffect.ts @@ -0,0 +1,28 @@ +import type * as React from 'react'; +import shim from '../shim'; + +type CleanupCallback = (()=> void)|null; +export type EffectFunction = ()=> CleanupCallback; +const { useRef, useMemo, useEffect } = shim.react(); + +// Like useEffect, but runs as soon as possible. +const useNowEffect = (effect: EffectFunction, dependencies: React.DependencyList) => { + const lastCleanup = useRef(null); + + const cleanupCallback = useMemo(() => { + lastCleanup.current?.(); + lastCleanup.current = null; + + return effect() ?? null; + // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- This is a custom hook + }, dependencies); + lastCleanup.current = cleanupCallback; + + useEffect(() => { + return () => { + lastCleanup.current?.(); + lastCleanup.current = null; + }; + }, []); +}; +export default useNowEffect; diff --git a/packages/lib/models/ItemChange.ts b/packages/lib/models/ItemChange.ts index afec9175e89..c1483c70b78 100644 --- a/packages/lib/models/ItemChange.ts +++ b/packages/lib/models/ItemChange.ts @@ -9,6 +9,19 @@ export interface ChangeSinceIdOptions { fields?: string[]; } +interface BaseAddOptions { + changeSource?: number|null; + changeId?: string|null; +} + +interface AddOneOptions extends BaseAddOptions { + beforeChangeItemJson?: string|null; +} + +interface AddMultiOptions extends BaseAddOptions { + beforeChangeItemJsons?: string[]|null; +} + export default class ItemChange extends BaseModel { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -33,8 +46,12 @@ export default class ItemChange extends BaseModel { return BaseModel.TYPE_ITEM_CHANGE; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public static async addMulti(itemType: ModelType, itemIds: string[], type: number, changeSource: any = null, beforeChangeItemJsons: string[] = null) { + public static async addMulti( + itemType: ModelType, + itemIds: string[], + type: number, + { changeSource = null, changeId = null, beforeChangeItemJsons = null }: AddMultiOptions = {}, + ) { if (!itemIds.length) return; if (changeSource === null) changeSource = ItemChange.SOURCE_UNSPECIFIED; @@ -72,16 +89,19 @@ export default class ItemChange extends BaseModel { for (const itemId of itemIds) { eventManager.emit(EventName.ItemChange, { itemType: itemType, - itemId: itemId, + itemId, + changeId, eventType: type, }); } } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public static async add(itemType: ModelType, itemId: string, type: number, changeSource: any = null, beforeChangeItemJson: string = null) { - await this.addMulti(itemType, [itemId], type, changeSource, beforeChangeItemJson ? [beforeChangeItemJson] : null); + public static async add(itemType: ModelType, itemId: string, type: number, options: AddOneOptions = {}) { + await this.addMulti(itemType, [itemId], type, { + ...options, + beforeChangeItemJsons: options?.beforeChangeItemJson ? [options.beforeChangeItemJson] : null, + }); } public static async lastChangeId() { diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts index bc93f781b21..5fe0c46d648 100644 --- a/packages/lib/models/Note.ts +++ b/packages/lib/models/Note.ts @@ -825,7 +825,9 @@ export default class Note extends BaseItem { let savedNote = await super.save(o, options); - void ItemChange.add(BaseModel.TYPE_NOTE, savedNote.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson); + void ItemChange.add(BaseModel.TYPE_NOTE, savedNote.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, { + changeSource, changeId: options?.changeId, beforeChangeItemJson: beforeNoteJson, + }); if (dispatchUpdateAction) { // Ensures that any note added to the state has all the required @@ -843,6 +845,7 @@ export default class Note extends BaseItem { provisional: isProvisional, ignoreProvisionalFlag: ignoreProvisionalFlag, changedFields: changedFields, + changeId: options?.changeId, ...options?.dispatchOptions, }); } @@ -913,7 +916,9 @@ export default class Note extends BaseItem { for (let i = 0; i < processIds.length; i++) { const id = processIds[i]; - void ItemChange.add(BaseModel.TYPE_NOTE, id, changeType, changeSource, beforeChangeItems[id]); + void ItemChange.add(BaseModel.TYPE_NOTE, id, changeType, { + changeSource, beforeChangeItemJson: beforeChangeItems[id], + }); this.dispatch({ type: 'NOTE_DELETE', diff --git a/packages/lib/models/utils/types.ts b/packages/lib/models/utils/types.ts index bbea1b0ec2b..cb88f996ea1 100644 --- a/packages/lib/models/utils/types.ts +++ b/packages/lib/models/utils/types.ts @@ -50,6 +50,11 @@ export interface SaveOptions { ignoreProvisionalFlag?: boolean; dispatchUpdateAction?: boolean; dispatchOptions?: { preserveSelection: boolean }; - changeSource?: number; disableReadOnlyCheck?: boolean; + + changeSource?: number; + + // The changeId is included in events emitted by some .save calls. Use this to pair a call + // to Item.save(...) with events emitted by that call. + changeId?: string; } diff --git a/packages/lib/reducer.test.ts b/packages/lib/reducer.test.ts index 87b9abec5a2..15eaae44e93 100644 --- a/packages/lib/reducer.test.ts +++ b/packages/lib/reducer.test.ts @@ -1,5 +1,5 @@ import { setupDatabaseAndSynchronizer, switchClient, createNTestNotes, createNTestFolders, createNTestTags } from './testing/test-utils'; -import reducer, { defaultState, MAX_HISTORY, State } from './reducer'; +import reducer, { defaultState, defaultWindowId, MAX_HISTORY, State } from './reducer'; import { BaseItemEntity, FolderEntity, NoteEntity, TagEntity } from './services/database/types'; import Note from './models/Note'; import BaseModel from './BaseModel'; @@ -73,6 +73,32 @@ function createExpectedState(items: BaseItemEntity[], keepIndexes: number[], sel return expected; } +const createBackgroundWindow = (state: State, windowId: string, selectedNote: NoteEntity, notes: NoteEntity[]) => { + state = reducer(state, { + type: 'WINDOW_OPEN', + windowId, + folderId: selectedNote.parent_id, + noteId: selectedNote.id, + }); + const previousWindowId = state.windowId; + + state = reducer(state, { + type: 'WINDOW_FOCUS', + windowId, + }); + state = reducer(state, { + type: 'NOTE_UPDATE_ALL', + notes: notes, + notesSource: 'test', + }); + state = reducer(state, { + type: 'WINDOW_FOCUS', + windowId: previousWindowId, + }); + + return state; +}; + function getIds(items: BaseItemEntity[], indexes: number[]|null = null) { const ids = []; for (let i = 0; i < items.length; i++) { @@ -256,6 +282,30 @@ describe('reducer', () => { expect(state.selectedNoteIds).toEqual(expected.selectedIds); })); + it('should delete notes from background window states', (async () => { + const folders = await createNTestFolders(1); + const notes = await createNTestNotes(5, folders[0]); + let state = initTestState(folders, 0, notes, [0, 2, 4]); + + const backgroundWindowId = 'window1'; + state = createBackgroundWindow(state, backgroundWindowId, notes[2], notes); + + state = reducer(state, { type: 'NOTE_DELETE', id: notes[2].id }); + state = reducer(state, { type: 'NOTE_DELETE', id: notes[0].id }); + + let expected = createExpectedState(notes, [1, 3, 4], [1]); + expect(getIds(state.notes)).toEqual(getIds(expected.items)); + expect(state.selectedNoteIds).toEqual(expected.selectedIds); + + state = reducer(state, { + type: 'WINDOW_FOCUS', + windowId: backgroundWindowId, + }); + expected = createExpectedState(notes, [1, 3, 4], [3]); + expect(getIds(state.notes)).toEqual(getIds(expected.items)); + expect(state.selectedNoteIds).toEqual(expected.selectedIds); + })); + // tests for FOLDER_DELETE it('should delete selected notebook', (async () => { const folders = await createNTestFolders(5); @@ -389,6 +439,30 @@ describe('reducer', () => { expect(getIds(state.backwardHistoryNotes)).not.toContain(notes[2].id); })); + it('should remove deleted note from history in background window', async () => { + const folders = await createNTestFolders(1); + const notes = await createNTestNotes(5, folders[0]); + let state = initTestState(folders, 0, notes, [0]); + + const windowId1 = 'window1'; + state = createBackgroundWindow(state, windowId1, notes[0], notes); + + state = goToNote(notes, [1], state); + state = goToNote(notes, [2], state); + state = goToNote(notes, [3], state); + state = goToNote(notes, [4], state); + + expect(getIds(state.backwardHistoryNotes)).toEqual([notes[0].id, notes[1].id, notes[2].id, notes[3].id]); + + // Remove a note in another window + state = reducer(state, { type: 'WINDOW_FOCUS', windowId: windowId1 }); + state = reducer(state, { type: 'NOTE_DELETE', id: notes[2].id }); + state = reducer(state, { type: 'WINDOW_FOCUS', windowId: defaultWindowId }); + + // should have removed the note from history in the unfocused window + expect(getIds(state.backwardHistoryNotes)).toEqual([notes[0].id, notes[1].id, notes[3].id]); + }); + it('should remove all notes of a deleted notebook from history', (async () => { const folders = await createNTestFolders(2); const notes = []; @@ -668,4 +742,45 @@ describe('reducer', () => { expect(state.notes.every(n => n.id !== notes[0].id)).toBe(true); expect(state.selectedFolderId).toBe(folders[0].id); }); + + // window tests + test('switching windows should move state from background to the foreground', async () => { + const folders = await createNTestFolders(2); + const notes1 = await createNTestNotes(3, folders[0]); + const noteIds1 = getIds(notes1); + const notes2 = await createNTestNotes(4, folders[1]); + const noteIds2 = getIds(notes2); + let state = initTestState(folders, 0, notes1, [0]); + + const window1Id = 'window1'; + const window2Id = 'window2'; + state = createBackgroundWindow(state, window1Id, notes1[2], notes1); + state = createBackgroundWindow(state, window2Id, notes2[1], notes2); + + const checkCurrentState = (windowId: string) => { + expect(state.windowId).toBe(windowId); + expect(getIds(state.notes)).toEqual(windowId === window2Id ? noteIds2 : noteIds1); + expect(state.selectedFolderId).toBe(windowId === window2Id ? folders[1].id : folders[0].id); + + let expectedSelectedNoteIds = [notes1[0].id]; + if (windowId === window2Id) { + expectedSelectedNoteIds = [notes2[1].id]; + } else if (windowId === window1Id) { + expectedSelectedNoteIds = [notes1[2].id]; + } + expect(state.selectedNoteIds).toEqual(expectedSelectedNoteIds); + }; + + const navigationPattern = [ + window1Id, window2Id, defaultWindowId, defaultWindowId, window2Id, window1Id, defaultWindowId, + ]; + + for (const windowId of navigationPattern) { + state = reducer(state, { + type: 'WINDOW_FOCUS', + windowId, + }); + checkCurrentState(windowId); + } + }); }); diff --git a/packages/lib/reducer.ts b/packages/lib/reducer.ts index 0254aaa060f..618681f2b32 100644 --- a/packages/lib/reducer.ts +++ b/packages/lib/reducer.ts @@ -7,7 +7,7 @@ import BaseModel from './BaseModel'; import { Store } from 'redux'; import { ProfileConfig } from './services/profileConfig/types'; import * as ArrayUtils from './ArrayUtils'; -import { FolderEntity, NoteEntity } from './services/database/types'; +import { FolderEntity, NoteEntity, NoteTagEntity } from './services/database/types'; import { getListRendererIds } from './services/noteList/renderers'; import { ProcessResultsRow } from './services/search/SearchEngine'; import { getDisplayParentId } from './services/trash'; @@ -70,20 +70,15 @@ export interface StateLastDeletion { timestamp: number; } -export interface State { +export interface WindowState { + windowId: string; notes: NoteEntity[]; noteSelectionEnabled?: boolean; notesSource: string; notesParentType: string; - folders: FolderEntity[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - tags: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - masterKeys: any[]; - notLoadedMasterKeys: string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - searches: any[]; - highlightedWords: string[]; + selectedNoteTags: NoteTagEntity[]; + searchQuery: string; + selectedNoteIds: string[]; selectedNoteHash: string; selectedFolderId: string; @@ -91,7 +86,53 @@ export interface State { selectedSearchId: string; selectedItemType: string; selectedSmartFilterId: string; + + backwardHistoryNotes: NoteEntity[]; + forwardHistoryNotes: NoteEntity[]; lastSelectedNotesIds: StateLastSelectedNotesIds; +} + +export const defaultWindowId = 'default'; +export const defaultWindowState: WindowState = { + windowId: defaultWindowId, + searchQuery: '', + notes: [], + notesSource: '', + notesParentType: null, + selectedNoteIds: [], + selectedNoteHash: '', + selectedFolderId: null, + selectedTagId: null, + selectedSearchId: null, + selectedSmartFilterId: null, + selectedItemType: 'note', + selectedNoteTags: [], + backwardHistoryNotes: [], + forwardHistoryNotes: [], + lastSelectedNotesIds: { + Folder: {}, + Tag: {}, + Search: {}, + }, +}; + +export interface EditorNoteStatuses { + [id: string]: string; +} + +export interface State extends WindowState { + // Contains state specific to windows that currently don't have focus. + // See spec/background_windows.md for details. + backgroundWindows: Record; + + folders: FolderEntity[]; + tags: NoteTagEntity[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + masterKeys: any[]; + notLoadedMasterKeys: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + searches: any[]; + highlightedWords: string[]; showSideMenu: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied screens: any; @@ -99,7 +140,6 @@ export interface State { syncStarted: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied syncReport: any; - searchQuery: string; searchResults: ProcessResultsRow[]; settings: Partial; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -108,22 +148,16 @@ export interface State { biometricsDone: boolean; hasDisabledSyncItems: boolean; hasDisabledEncryptionItems: boolean; - customCss: string; + customViewerCss: string; + customChromeCssPaths: string[]; collapsedFolderIds: string[]; clipperServer: StateClipperServer; decryptionWorker: StateDecryptionWorker; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - selectedNoteTags: any[]; resourceFetcher: StateResourceFetcher; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - backwardHistoryNotes: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - forwardHistoryNotes: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied pluginsLegacy: any; provisionalNoteIds: string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - editorNoteStatuses: any; + editorNoteStatuses: EditorNoteStatuses; isInsertingNotes: boolean; hasEncryptedItems: boolean; needApiAuth: boolean; @@ -141,27 +175,14 @@ export interface State { } export const defaultState: State = { - notes: [], - notesSource: '', - notesParentType: null, + ...defaultWindowState, + backgroundWindows: {}, folders: [], tags: [], masterKeys: [], notLoadedMasterKeys: [], searches: [], highlightedWords: [], - selectedNoteIds: [], - selectedNoteHash: '', - selectedFolderId: null, - selectedTagId: null, - selectedSearchId: null, - selectedSmartFilterId: null, - selectedItemType: 'note', - lastSelectedNotesIds: { - Folder: {}, - Tag: {}, - Search: {}, - }, showSideMenu: false, screens: {}, historyCanGoBack: false, @@ -175,7 +196,8 @@ export const defaultState: State = { biometricsDone: false, hasDisabledSyncItems: false, hasDisabledEncryptionItems: false, - customCss: '', + customViewerCss: '', + customChromeCssPaths: [], collapsedFolderIds: [], clipperServer: { startState: 'idle', @@ -189,13 +211,10 @@ export const defaultState: State = { decryptedItemCount: 0, skippedItemCount: 0, }, - selectedNoteTags: [], resourceFetcher: { toFetchCount: 0, fetchingCount: 0, }, - backwardHistoryNotes: [], - forwardHistoryNotes: [], // pluginsLegacy is the original plugin system, which eventually was used only for GotoAnything. // GotoAnything should be refactored to part of core and when it's done the pluginsLegacy key can // be removed. It was originally named "plugins", then renamed "pluginsLegacy" so as not to conflict @@ -290,7 +309,7 @@ class StateUtils { return selectArrayShallow(props, cacheKey); } - public oneNoteSelected(state: State): boolean { + public oneNoteSelected(state: WindowState): boolean { return state.selectedNoteIds.length === 1; } @@ -334,7 +353,7 @@ class StateUtils { return false; } - public parentItem(state: State) { + public parentItem(state: WindowState) { const t = state.notesParentType; let id = null; if (t === 'Folder') id = state.selectedFolderId; @@ -344,7 +363,7 @@ class StateUtils { return { type: t, id: id }; } - public lastSelectedNoteIds(state: State): string[] { + public lastSelectedNoteIds(state: WindowState): string[] { const parent = this.parentItem(state); if (!parent) return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -352,17 +371,55 @@ class StateUtils { return output ? output : []; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public selectedNote(state: State): any { + public selectedNote(state: WindowState): NoteEntity { const noteId = this.selectedNoteId(state); return noteId ? BaseModel.byId(state.notes, noteId) : null; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public selectedNoteId(state: State): any { + public selectedNoteId(state: WindowState): string|null { return state.selectedNoteIds.length ? state.selectedNoteIds[0] : null; } + public activeWindowId(state: State) { + return state.windowId; + } + + private allWindowIds(state: State) { + return [state.windowId, ...Object.keys(state.backgroundWindows)]; + } + + public allWindowStates(state: T) { + return this.allWindowIds(state).map(id => this.windowStateById(state, id)); + } + + public windowStateById( + state: StateType, id: string, + ) { + // States for the different Joplin apps can have different types for backgroundWindows -- this + // makes sure that the correct type is returned. + type AppWindowState = StateType['backgroundWindows'][keyof StateType['backgroundWindows']]; + const result = id === state.windowId ? state : state.backgroundWindows[id]; + return result as AppWindowState; + } + + public mainWindowState(state: State) { + return this.windowStateById(state, defaultWindowId); + } + + public secondaryWindowStates(state: State) { + const windowIds = [state.windowId, ...Object.keys(state.backgroundWindows)]; + return windowIds + .filter(id => (id !== defaultWindowId)) + .map(id => this.windowStateById(state, id)); + } + + public windowIdToSelectedNoteIds(state: State) { + const result: Record = {}; + for (const id of this.allWindowIds(state)) { + result[id] = this.windowStateById(state, id).selectedNoteIds; + } + return result; + } } export const stateUtils: StateUtils = new StateUtils(); @@ -407,8 +464,8 @@ function removeAdjacentDuplicates(items: any[]) { // When deleting a note, tag or folder // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied function handleItemDelete(draft: Draft, action: any) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const map: any = { + type SelectionKey = 'selectedFolderId'|'selectedNoteIds'|'selectedTagId'|'selectedSearchId'; + const map: Record = { FOLDER_DELETE: ['folders', 'selectedFolderId', true], NOTE_DELETE: ['notes', 'selectedNoteIds', false], TAG_DELETE: ['tags', 'selectedTagId', true], @@ -419,68 +476,70 @@ function handleItemDelete(draft: Draft, action: any) { const selectedItemKey = map[action.type][1]; const isSingular = map[action.type][2]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const selectedItemKeys = isSingular ? [(draft as any)[selectedItemKey]] : (draft as any)[selectedItemKey]; - const isSelected = selectedItemKeys.includes(action.id); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const items = (draft as any)[listKey]; - const newItems = []; - let newSelectedIndexes: number[] = []; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (isSelected) { - // the selected item is deleted so select the following item - // if multiple items are selected then just use the first one - if (selectedItemKeys[0] === item.id) { - newSelectedIndexes.push(newItems.length); + for (const windowDraft of stateUtils.allWindowStates(draft)) { + const selectedItemKeys = isSingular ? [windowDraft[selectedItemKey]] : windowDraft[selectedItemKey]; + const isSelected = selectedItemKeys.includes(action.id); + + const items = listKey in windowDraft ? windowDraft[listKey as keyof WindowState] : draft[listKey]; + const newItems = []; + let newSelectedIndexes: number[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (isSelected) { + // the selected item is deleted so select the following item + // if multiple items are selected then just use the first one + if (selectedItemKeys[0] === item.id) { + newSelectedIndexes.push(newItems.length); + } + } else { + // the selected item/s is not deleted so keep it selected + if (selectedItemKeys.includes(item.id)) { + newSelectedIndexes.push(newItems.length); + } } - } else { - // the selected item/s is not deleted so keep it selected - if (selectedItemKeys.includes(item.id)) { - newSelectedIndexes.push(newItems.length); + if (item.id === action.id) { + continue; } + newItems.push(item); } - if (item.id === action.id) { - continue; - } - newItems.push(item); - } - if (newItems.length === 0) { - newSelectedIndexes = []; // no remaining items so no selection + if (newItems.length === 0) { + newSelectedIndexes = []; // no remaining items so no selection - } else if (newSelectedIndexes.length === 0) { - newSelectedIndexes.push(0); // no selection exists so select the top + } else if (newSelectedIndexes.length === 0) { + newSelectedIndexes.push(0); // no selection exists so select the top - } else { - // when the items at end of list are deleted then select the end - for (let i = 0; i < newSelectedIndexes.length; i++) { - if (newSelectedIndexes[i] >= newItems.length) { - newSelectedIndexes = [newItems.length - 1]; - break; + } else { + // when the items at end of list are deleted then select the end + for (let i = 0; i < newSelectedIndexes.length; i++) { + if (newSelectedIndexes[i] >= newItems.length) { + newSelectedIndexes = [newItems.length - 1]; + break; + } } } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - (draft as any)[listKey] = newItems; + if (listKey in windowDraft) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + (windowDraft as any)[listKey] = newItems; + } - const newIds = []; - for (let i = 0; i < newSelectedIndexes.length; i++) { - newIds.push(newItems[newSelectedIndexes[i]].id); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - (draft as any)[selectedItemKey] = isSingular ? newIds[0] : newIds; + const newIds = []; + for (let i = 0; i < newSelectedIndexes.length; i++) { + newIds.push(newItems[newSelectedIndexes[i]].id); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + (windowDraft as any)[selectedItemKey] = isSingular ? newIds[0] : newIds; - if ((newIds.length === 0) && draft.notesParentType !== 'Folder') { - draft.notesParentType = 'Folder'; + if ((newIds.length === 0) && windowDraft.notesParentType !== 'Folder') { + windowDraft.notesParentType = 'Folder'; + } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -function updateOneItem(draft: Draft, action: any, keyName = '') { +function updateOneItem(draft: Draft, action: any, keyName = '') { let itemsKey = null; if (keyName) { itemsKey = keyName; } else { if (action.type === 'TAG_UPDATE_ONE') itemsKey = 'tags'; @@ -757,25 +816,6 @@ function handleHistory(draft: Draft, action: any) { draft.backwardHistoryNotes = draft.backwardHistoryNotes.concat(currentNote).slice(-MAX_HISTORY); } break; - case 'NOTE_UPDATE_ONE': { - const modNote = action.note; - - draft.backwardHistoryNotes = draft.backwardHistoryNotes.map(note => { - if (note.id === modNote.id) { - return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; - } - return note; - }); - - draft.forwardHistoryNotes = draft.forwardHistoryNotes.map(note => { - if (note.id === modNote.id) { - return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; - } - return note; - }); - - break; - } case 'SEARCH_UPDATE': if (currentNote && (draft.backwardHistoryNotes.length === 0 || draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1].id !== currentNote.id)) { @@ -787,33 +827,127 @@ function handleHistory(draft: Draft, action: any) { case 'SEARCH_RESULTS_SET': draft.searchResults = action.value; break; + } - case 'FOLDER_DELETE': - draft.backwardHistoryNotes = draft.backwardHistoryNotes.filter(note => note.parent_id !== action.id); - draft.forwardHistoryNotes = draft.forwardHistoryNotes.filter(note => note.parent_id !== action.id); + const updateWindowHistory = (windowDraft: Draft) => { + switch (action.type) { + case 'NOTE_UPDATE_ONE': { + const modNote = action.note; - draft.backwardHistoryNotes = removeAdjacentDuplicates(draft.backwardHistoryNotes); - draft.forwardHistoryNotes = removeAdjacentDuplicates(draft.forwardHistoryNotes); - break; - case 'NOTE_DELETE': { - draft.backwardHistoryNotes = draft.backwardHistoryNotes.filter(note => note.id !== action.id); - draft.forwardHistoryNotes = draft.forwardHistoryNotes.filter(note => note.id !== action.id); + windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.map(note => { + if (note.id === modNote.id) { + return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; + } + return note; + }); + + windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.map(note => { + if (note.id === modNote.id) { + return { ...note, parent_id: modNote.parent_id, selectedFolderId: modNote.parent_id }; + } + return note; + }); + + break; + } - draft.backwardHistoryNotes = removeAdjacentDuplicates(draft.backwardHistoryNotes); - draft.forwardHistoryNotes = removeAdjacentDuplicates(draft.forwardHistoryNotes); + case 'FOLDER_DELETE': + windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.filter(note => note.parent_id !== action.id); + windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.filter(note => note.parent_id !== action.id); + + windowDraft.backwardHistoryNotes = removeAdjacentDuplicates(windowDraft.backwardHistoryNotes); + windowDraft.forwardHistoryNotes = removeAdjacentDuplicates(windowDraft.forwardHistoryNotes); + break; + case 'NOTE_DELETE': { + windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.filter(note => note.id !== action.id); + windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.filter(note => note.id !== action.id); - // Fix the case where after deletion the currently selected note is also the latest in history - const selectedNoteIds = draft.selectedNoteIds; - if (selectedNoteIds.length && draft.backwardHistoryNotes.length && draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { - draft.backwardHistoryNotes = draft.backwardHistoryNotes.slice(0, draft.backwardHistoryNotes.length - 1); + windowDraft.backwardHistoryNotes = removeAdjacentDuplicates(windowDraft.backwardHistoryNotes); + windowDraft.forwardHistoryNotes = removeAdjacentDuplicates(windowDraft.forwardHistoryNotes); + + // Fix the case where after deletion the currently selected note is also the latest in history + const selectedNoteIds = windowDraft.selectedNoteIds; + if (selectedNoteIds.length && windowDraft.backwardHistoryNotes.length && windowDraft.backwardHistoryNotes[windowDraft.backwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { + windowDraft.backwardHistoryNotes = windowDraft.backwardHistoryNotes.slice(0, windowDraft.backwardHistoryNotes.length - 1); + } + if (selectedNoteIds.length && windowDraft.forwardHistoryNotes.length && windowDraft.forwardHistoryNotes[windowDraft.forwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { + windowDraft.forwardHistoryNotes = windowDraft.forwardHistoryNotes.slice(0, windowDraft.forwardHistoryNotes.length - 1); + } + break; } - if (selectedNoteIds.length && draft.forwardHistoryNotes.length && draft.forwardHistoryNotes[draft.forwardHistoryNotes.length - 1].id === selectedNoteIds[0]) { - draft.forwardHistoryNotes = draft.forwardHistoryNotes.slice(0, draft.forwardHistoryNotes.length - 1); + } + }; + + updateWindowHistory(draft); + for (const id in draft.backgroundWindows) { + updateWindowHistory(draft.backgroundWindows[id]); + } +} + +type WindowAction = { + type: 'WINDOW_OPEN'; + windowId: string; + folderId: string; + noteId: string; + defaultAppWindowState: Record; +}|{ + type: 'WINDOW_FOCUS'|'WINDOW_CLOSE'; + windowId: string; +}; + +const handleWindowActions = (draft: Draft, action: WindowAction) => { + switch (action.type) { + + case 'WINDOW_OPEN': { + if (action.windowId in draft.backgroundWindows) { + throw new Error(`Window with id ${action.windowId} is already open!`); + } + + draft.backgroundWindows[action.windowId] = { + ...defaultWindowState, + ...action.defaultAppWindowState, + + lastSelectedNotesIds: { + ...defaultWindowState.lastSelectedNotesIds, + Folder: { + [action.folderId]: [action.noteId], + }, + }, + notesParentType: 'Folder', + selectedFolderId: action.folderId, + windowId: action.windowId, + selectedNoteIds: [action.noteId], + }; + break; + } + case 'WINDOW_FOCUS': { + // Only allow bringing a background window to the foreground + if (draft.windowId !== action.windowId) { + const windowId = action.windowId; + const previousWindowId = draft.windowId; + + const focusingWindowState = draft.backgroundWindows[windowId]; + const previousWindowState = { ...defaultWindowState }; + + for (const key of Object.keys(focusingWindowState)) { + const stateKey = key as keyof WindowState; + + type AssignableWindowState = Record; + (previousWindowState as AssignableWindowState)[stateKey] = draft[stateKey]; + (draft as AssignableWindowState)[stateKey] = focusingWindowState[stateKey]; + } + + delete draft.backgroundWindows[windowId]; + draft.backgroundWindows[previousWindowId] = previousWindowState; } break; } + case 'WINDOW_CLOSE': { + delete draft.backgroundWindows[action.windowId]; + break; } -} + } +}; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -829,6 +963,8 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { handleHistory(draft, action); } + handleWindowActions(draft, action); + try { switch (action.type) { @@ -952,87 +1088,94 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { case 'NOTE_UPDATE_ONE': { const modNote: NoteEntity = action.note; - const isViewingAllNotes = (draft.notesParentType === 'SmartFilter' && draft.selectedSmartFilterId === ALL_NOTES_FILTER_ID); - const isViewingConflictFolder = draft.notesParentType === 'Folder' && draft.selectedFolderId === Folder.conflictFolderId(); - - const noteIsInFolder = function(note: NoteEntity, folderId: string) { - if (note.is_conflict && isViewingConflictFolder) return true; - const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); - return folderId === noteDisplayParentId; - }; - - let movedNotePreviousIndex = 0; - let noteFolderHasChanged = false; - const newNotes = draft.notes.slice(); - let found = false; - for (let i = 0; i < newNotes.length; i++) { - const n = newNotes[i]; - if (n.id === modNote.id) { - const previousDisplayParentId = ('parent_id' in n) ? getDisplayParentId(n, draft.folders.find(f => f.id === n.parent_id)) : ''; - if (n.is_conflict && !modNote.is_conflict) { - // Note was a conflict but was moved outside of - // the conflict folder - newNotes.splice(i, 1); - noteFolderHasChanged = true; - movedNotePreviousIndex = i; - } else if (isViewingAllNotes || noteIsInFolder(modNote, previousDisplayParentId)) { - // Note is still in the same folder - // Merge the properties that have changed (in modNote) into - // the object we already have. - newNotes[i] = { ...newNotes[i] }; - - for (const n in modNote) { - if (!modNote.hasOwnProperty(n)) continue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - (newNotes[i] as any)[n] = (modNote as any)[n]; + const handleWindowState = (windowDraft: Draft) => { + const isViewingAllNotes = (windowDraft.notesParentType === 'SmartFilter' && windowDraft.selectedSmartFilterId === ALL_NOTES_FILTER_ID); + const isViewingConflictFolder = windowDraft.notesParentType === 'Folder' && windowDraft.selectedFolderId === Folder.conflictFolderId(); + + const noteIsInFolder = function(note: NoteEntity, folderId: string) { + if (note.is_conflict && isViewingConflictFolder) return true; + const noteDisplayParentId = getDisplayParentId(note, draft.folders.find(f => f.id === note.parent_id)); + return folderId === noteDisplayParentId; + }; + + let movedNotePreviousIndex = 0; + let noteFolderHasChanged = false; + const newNotes = windowDraft.notes.slice(); + let found = false; + for (let i = 0; i < newNotes.length; i++) { + const n = newNotes[i]; + if (n.id === modNote.id) { + const previousDisplayParentId = ('parent_id' in n) ? getDisplayParentId(n, draft.folders.find(f => f.id === n.parent_id)) : ''; + if (n.is_conflict && !modNote.is_conflict) { + // Note was a conflict but was moved outside of + // the conflict folder + newNotes.splice(i, 1); + noteFolderHasChanged = true; + movedNotePreviousIndex = i; + } else if (isViewingAllNotes || noteIsInFolder(modNote, previousDisplayParentId)) { + // Note is still in the same folder + // Merge the properties that have changed (in modNote) into + // the object we already have. + newNotes[i] = { ...newNotes[i] }; + + for (const n in modNote) { + if (!modNote.hasOwnProperty(n)) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + (newNotes[i] as any)[n] = (modNote as any)[n]; + } + } else { + // Note has moved to a different folder + newNotes.splice(i, 1); + noteFolderHasChanged = true; + movedNotePreviousIndex = i; } - } else { - // Note has moved to a different folder - newNotes.splice(i, 1); - noteFolderHasChanged = true; - movedNotePreviousIndex = i; + found = true; + break; } - found = true; - break; } - } - // Note was not found - if the current folder is the same as the note folder, - // add it to it. - if (!found) { - if (isViewingAllNotes || noteIsInFolder(modNote, draft.selectedFolderId)) { - newNotes.push(modNote); + // Note was not found - if the current folder is the same as the note folder, + // add it to it. + if (!found) { + if (isViewingAllNotes || noteIsInFolder(modNote, windowDraft.selectedFolderId)) { + newNotes.push(modNote); + } } - } - draft.notes = newNotes; - - // Ensure that the selected note is still in the current folder. - // For example, if the user drags the current note to a different folder, - // a new note should be selected. - // In some cases, however, the selection needs to be preserved (e.g. the mobile app). - if (noteFolderHasChanged && !action.preserveSelection) { - let newIndex = movedNotePreviousIndex; - if (newIndex >= newNotes.length) newIndex = newNotes.length - 1; - if (!newNotes.length) newIndex = -1; - draft.selectedNoteIds = newIndex >= 0 ? [newNotes[newIndex].id] : []; - } + windowDraft.notes = newNotes; + + // Ensure that the selected note is still in the current folder. + // For example, if the user drags the current note to a different folder, + // a new note should be selected. + // In some cases, however, the selection needs to be preserved (e.g. the mobile app). + if (noteFolderHasChanged && !action.preserveSelection) { + let newIndex = movedNotePreviousIndex; + if (newIndex >= newNotes.length) newIndex = newNotes.length - 1; + if (!newNotes.length) newIndex = -1; + windowDraft.selectedNoteIds = newIndex >= 0 ? [newNotes[newIndex].id] : []; + } - if (!action.ignoreProvisionalFlag) { - let newProvisionalNoteIds = draft.provisionalNoteIds; + if (!action.ignoreProvisionalFlag) { + let newProvisionalNoteIds = draft.provisionalNoteIds; - if (action.provisional) { - newProvisionalNoteIds = newProvisionalNoteIds.slice(); - newProvisionalNoteIds.push(modNote.id); - } else { const idx = newProvisionalNoteIds.indexOf(modNote.id); - if (idx >= 0) { + if (action.provisional) { + if (idx < 0) { + newProvisionalNoteIds = newProvisionalNoteIds.slice(); + newProvisionalNoteIds.push(modNote.id); + } + } else if (idx >= 0) { newProvisionalNoteIds = newProvisionalNoteIds.slice(); newProvisionalNoteIds.splice(idx, 1); } + + draft.provisionalNoteIds = newProvisionalNoteIds; } + }; - draft.provisionalNoteIds = newProvisionalNoteIds; + handleWindowState(draft); + for (const backgroundWindow of Object.values(draft.backgroundWindows)) { + handleWindowState(backgroundWindow); } } break; @@ -1116,10 +1259,15 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { case 'TAG_UPDATE_ONE': { - // We only want to update the selected note tags if the tag belongs to the currently open note - const selectedNoteHasTag = !!draft.selectedNoteTags.find(tag => tag.id === action.item.id); updateOneItem(draft, action); - if (selectedNoteHasTag) updateOneItem(draft, action, 'selectedNoteTags'); + + for (const windowStateDraft of stateUtils.allWindowStates(draft)) { + // We only want to update the selected note tags if the tag belongs to the currently open note + const selectedNoteHasTag = !!windowStateDraft.selectedNoteTags.find(tag => tag.id === action.item.id); + if (selectedNoteHasTag) { + updateOneItem(windowStateDraft, action, 'selectedNoteTags'); + } + } } break; @@ -1127,7 +1275,9 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { { updateOneItem(draft, action, 'tags'); const tagRemoved = action.item; - draft.selectedNoteTags = removeItemFromArray(draft.selectedNoteTags, 'id', tagRemoved.id); + for (const windowStateDraft of stateUtils.allWindowStates(draft)) { + windowStateDraft.selectedNoteTags = removeItemFromArray(windowStateDraft.selectedNoteTags, 'id', tagRemoved.id); + } } break; @@ -1292,8 +1442,15 @@ const reducer = produce((draft: Draft = defaultState, action: any) => { } break; - case 'CUSTOM_CSS_APPEND': - draft.customCss += action.css; + case 'CUSTOM_VIEWER_CSS_APPEND': + draft.customViewerCss += action.css; + break; + + case 'CUSTOM_CHROME_CSS_ADD': + // To enable/disable custom CSS, some plugins add the same chrome CSS file multiple times. + // For performance, only apply the last copy of each file. + draft.customChromeCssPaths = draft.customChromeCssPaths.filter(path => path !== action.filePath); + draft.customChromeCssPaths.push(action.filePath); break; case 'SET_NOTE_TAGS': diff --git a/packages/lib/services/CommandService.test.ts b/packages/lib/services/CommandService.test.ts index bd28aa2d821..446f4b3c9c6 100644 --- a/packages/lib/services/CommandService.test.ts +++ b/packages/lib/services/CommandService.test.ts @@ -31,6 +31,7 @@ function createCommand(name: string, options: any): TestCommand { execute: options.execute, }; + if (options.getPriority) runtime.getPriority = options.getPriority; if (options.enabledCondition) runtime.enabledCondition = options.enabledCondition; return { declaration, runtime }; @@ -38,9 +39,13 @@ function createCommand(name: string, options: any): TestCommand { function registerCommand(service: CommandService, cmd: TestCommand) { service.registerDeclaration(cmd.declaration); - service.registerRuntime(cmd.declaration.name, cmd.runtime); + return service.registerRuntime(cmd.declaration.name, cmd.runtime); } +const registerSecondaryRuntime = (service: CommandService, commandName: string, runtime: CommandRuntime) => { + return service.registerRuntime(commandName, runtime, true); +}; + describe('services_CommandService', () => { beforeEach(async () => { @@ -160,6 +165,35 @@ describe('services_CommandService', () => { } })); + it('should support multiple runtimes for a command', async () => { + const service = newService(); + + const execute1 = jest.fn(); + const execute2 = jest.fn(); + + const firstRuntime = registerCommand(service, createCommand('test1', { + execute: execute1, + getPriority: () => 1, + })); + + registerSecondaryRuntime(service, 'test1', { + execute: execute2, + }); + + await service.execute('test1'); + + // Should prefer commands with a positive specified priority + expect(execute2).not.toHaveBeenCalled(); + expect(execute1).toHaveBeenCalledTimes(1); + + // Should be possible to deregister just one runtime + firstRuntime.deregister(); + + await service.execute('test1'); + expect(execute1).toHaveBeenCalledTimes(1); + expect(execute2).toHaveBeenCalledTimes(1); + }); + it('should create menu items from commands', (async () => { const service = newService(); const utils = new MenuUtils(service); diff --git a/packages/lib/services/CommandService.ts b/packages/lib/services/CommandService.ts index d6b8f0a3f63..8f7506c618b 100644 --- a/packages/lib/services/CommandService.ts +++ b/packages/lib/services/CommandService.ts @@ -22,6 +22,8 @@ export interface CommandRuntime { // Used for the (optional) toolbar button title // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied mapStateToTitle?(state: any): string; + // Used to break ties when commands are registered by different components. + getPriority?(state: State): number; } export interface CommandDeclaration { @@ -55,7 +57,7 @@ export interface CommandDeclaration { export interface Command { declaration: CommandDeclaration; - runtime?: CommandRuntime; + runtime?: CommandRuntime|CommandRuntime[]; } interface CommandSpec { @@ -63,7 +65,7 @@ interface CommandSpec { runtime: ()=> CommandRuntime; } -interface ComponentCommandSpec { +export interface ComponentCommandSpec { declaration: CommandDeclaration; runtime: (component: ComponentType)=> CommandRuntime; } @@ -100,6 +102,10 @@ export interface SearchResult { title: string; } +export interface RegisteredRuntime { + deregister: ()=> void; +} + export default class CommandService extends BaseService { private static instance_: CommandService; @@ -112,7 +118,7 @@ export default class CommandService extends BaseService { private commands_: Commands = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private store_: any; + private store_: ReduxStore; private devMode_: boolean; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied private stateToWhenClauseContext_: Function; @@ -203,14 +209,40 @@ export default class CommandService extends BaseService { }; } - public registerRuntime(commandName: string, runtime: CommandRuntime) { + public registerRuntime(commandName: string, runtime: CommandRuntime, allowMultiple = false): RegisteredRuntime { if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`); const command = this.commandByName(commandName); runtime = { ...runtime }; if (!runtime.enabledCondition) runtime.enabledCondition = 'true'; - command.runtime = runtime; + if (!allowMultiple) { + command.runtime = runtime; + } else { + if (!Array.isArray(command.runtime)) { + command.runtime = command.runtime ? [command.runtime] : []; + } + command.runtime.push(runtime); + } + + return { + // Like .deregisterRuntime, but deletes only the current runtime if there are multiple runtimes + // for the same command. + deregister: () => { + const command = this.commandByName(commandName); + if (Array.isArray(command.runtime)) { + command.runtime = command.runtime.filter(r => { + return r !== runtime; + }); + + if (command.runtime.length === 0) { + delete command.runtime; + } + } else if (command.runtime) { + delete command.runtime; + } + }, + }; } public registerCommands(commands: CommandSpec[]) { @@ -225,10 +257,20 @@ export default class CommandService extends BaseService { } } - public componentRegisterCommands(component: ComponentType, commands: ComponentCommandSpec[]) { + public componentRegisterCommands(component: ComponentType, commands: ComponentCommandSpec[], allowMultiple?: boolean) { + const runtimeHandles: RegisteredRuntime[] = []; for (const command of commands) { - CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component)); + runtimeHandles.push( + CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component), allowMultiple), + ); } + return { + deregister: () => { + for (const handle of runtimeHandles) { + handle.deregister(); + } + }, + }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -254,6 +296,22 @@ export default class CommandService extends BaseService { }; } + private getRuntime(command: Command) { + if (!Array.isArray(command.runtime)) return command.runtime; + if (!command.runtime.length) return null; + + let bestRuntime = null; + let bestRuntimeScore = -1; + for (const runtime of command.runtime) { + const score = runtime.getPriority?.(this.store_.getState()) ?? 0; + if (score >= bestRuntimeScore) { + bestRuntime = runtime; + bestRuntimeScore = score; + } + } + return bestRuntime; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public async execute(commandName: string, ...args: any[]): Promise { const command = this.commandByName(commandName); @@ -261,8 +319,11 @@ export default class CommandService extends BaseService { // times per seconds, so we should only display this message in // debug mode. if (commandName !== 'showModalMessage') this.logger().debug('CommandService::execute:', commandName, args); - if (!command.runtime) throw new Error(`Cannot execute a command without a runtime: ${commandName}`); - return command.runtime.execute(this.createContext(), ...args); + + const runtime = this.getRuntime(command); + if (!runtime) throw new Error(`Cannot execute a command without a runtime: ${commandName}`); + + return runtime.execute(this.createContext(), ...args); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -286,11 +347,13 @@ export default class CommandService extends BaseService { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public isEnabled(commandName: string, whenClauseContext: any = null): boolean { const command = this.commandByName(commandName); - if (!command || !command.runtime) return false; + if (!command) return false; + const runtime = this.getRuntime(command); + if (!runtime) return false; if (!whenClauseContext) whenClauseContext = this.currentWhenClauseContext(); - const exp = new WhenClause(command.runtime.enabledCondition, this.devMode_); + const exp = new WhenClause(runtime.enabledCondition, this.devMode_); return exp.evaluate(whenClauseContext); } @@ -300,12 +363,14 @@ export default class CommandService extends BaseService { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public title(commandName: string, state: any = null): string { const command = this.commandByName(commandName); - if (!command || !command.runtime) return null; + if (!command) return null; + const runtime = this.getRuntime(command); + if (!runtime) return null; state = state || this.store_.getState(); - if (command.runtime.mapStateToTitle) { - return command.runtime.mapStateToTitle(state); + if (runtime.mapStateToTitle) { + return runtime.mapStateToTitle(state); } else { return ''; } diff --git a/packages/lib/services/PostMessageService.ts b/packages/lib/services/PostMessageService.ts index c383d5de5ab..5172eea2429 100644 --- a/packages/lib/services/PostMessageService.ts +++ b/packages/lib/services/PostMessageService.ts @@ -53,6 +53,7 @@ type ViewMessageHandler = (message: any)=> void; interface Message { pluginId: string; + windowId: string; contentScriptId: string; viewId: string; from: MessageParticipant; @@ -120,9 +121,9 @@ export default class PostMessageService { let responder: MessageResponder = null; if (message.from === MessageParticipant.ContentScript) { - responder = this.responder(ResponderComponentType.NoteTextViewer, message.viewId); + responder = this.responder(ResponderComponentType.NoteTextViewer, message.viewId, message.windowId); } else if (message.from === MessageParticipant.UserWebview) { - responder = this.responder(ResponderComponentType.UserWebview, message.viewId); + responder = this.responder(ResponderComponentType.UserWebview, message.viewId, message.windowId); } if (!responder) { @@ -136,13 +137,16 @@ export default class PostMessageService { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private responder(type: ResponderComponentType, viewId: string): any { - return this.responders_[[type, viewId].join(':')]; + private responder(type: ResponderComponentType, viewId: string, windowId: string) { + return this.responders_[[type, viewId, windowId].join(':')]; + } + + public registerResponder(type: ResponderComponentType, viewId: string, windowId: string, responder: MessageResponder) { + this.responders_[[type, viewId, windowId].join(':')] = responder; } - public registerResponder(type: ResponderComponentType, viewId: string, responder: MessageResponder) { - this.responders_[[type, viewId].join(':')] = responder; + public unregisterResponder(type: ResponderComponentType, viewId: string, windowId: string) { + delete this.responders_[[type, viewId, windowId].join(':')]; } public registerViewMessageHandler(type: ResponderComponentType, viewId: string, callback: ViewMessageHandler) { @@ -153,8 +157,4 @@ export default class PostMessageService { delete this.viewMessageHandlers_[[type, viewId].join(':')]; } - public unregisterResponder(type: ResponderComponentType, viewId: string) { - delete this.responders_[[type, viewId].join(':')]; - } - } diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts index 32453c3007b..5d380247f17 100644 --- a/packages/lib/services/commands/stateToWhenClauseContext.ts +++ b/packages/lib/services/commands/stateToWhenClauseContext.ts @@ -11,6 +11,7 @@ import { getTrashFolderId } from '../trash'; export interface WhenClauseContextOptions { commandFolderId?: string; commandNoteId?: string; + windowId?: string; } export interface WhenClauseContext { @@ -50,13 +51,14 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau commandNoteId: '', ...options, }; + const windowState = options.windowId ? stateUtils.windowStateById(state, options.windowId) : state; - const selectedNoteIds = state.selectedNoteIds || []; + const selectedNoteIds = windowState.selectedNoteIds || []; const selectedNoteId = selectedNoteIds.length === 1 ? selectedNoteIds[0] : null; - const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(state.notes, selectedNoteId) : null; - const selectedNotes = BaseModel.modelsByIds(state.notes ?? [], selectedNoteIds); + const selectedNote: NoteEntity = selectedNoteId ? BaseModel.byId(windowState.notes, selectedNoteId) : null; + const selectedNotes = BaseModel.modelsByIds(windowState.notes ?? [], selectedNoteIds); - const commandFolderId = options.commandFolderId || state.selectedFolderId; + const commandFolderId = options.commandFolderId || windowState.selectedFolderId; const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null; const settings = state.settings || {}; @@ -67,8 +69,8 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau syncStarted: state.syncStarted, // Current location - inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(), - inTrash: !!((state.selectedFolderId === getTrashFolderId() && !!selectedNote?.deleted_time) || commandFolder && !!commandFolder.deleted_time), + inConflictFolder: windowState.selectedFolderId === Folder.conflictFolderId(), + inTrash: !!((windowState.selectedFolderId === getTrashFolderId() && !!selectedNote?.deleted_time) || commandFolder && !!commandFolder.deleted_time), // Note selection oneNoteSelected: !!selectedNote, @@ -80,11 +82,11 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau allSelectedNotesAreDeleted: !selectedNotes.find(n => !n.deleted_time), // Note history - historyhasBackwardNotes: state.backwardHistoryNotes && state.backwardHistoryNotes.length > 0, - historyhasForwardNotes: state.forwardHistoryNotes && state.forwardHistoryNotes.length > 0, + historyhasBackwardNotes: windowState.backwardHistoryNotes && windowState.backwardHistoryNotes.length > 0, + historyhasForwardNotes: windowState.forwardHistoryNotes && windowState.forwardHistoryNotes.length > 0, // Folder selection - oneFolderSelected: !!state.selectedFolderId, + oneFolderSelected: !!windowState.selectedFolderId, // Current note properties noteIsTodo: selectedNote ? !!selectedNote.is_todo : false, diff --git a/packages/lib/services/plugins/BasePlatformImplementation.ts b/packages/lib/services/plugins/BasePlatformImplementation.ts index c4b6392e329..06fd55962a5 100644 --- a/packages/lib/services/plugins/BasePlatformImplementation.ts +++ b/packages/lib/services/plugins/BasePlatformImplementation.ts @@ -2,7 +2,6 @@ // such as the clipboard, message dialog, etc. It allows having the same plugin import { VersionInfo } from './api/types'; -import { Implementation as WindowImplementation } from './api/JoplinWindow'; import { Implementation as ImagingImplementation } from './api/JoplinImaging'; export interface JoplinViewsDialogs { @@ -36,10 +35,6 @@ export default class BasePlatformImplementation { throw new Error('Not implemented: nativeImage'); } - public get window(): WindowImplementation { - throw new Error('Not implemented: window'); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public registerComponent(_name: string, _component: any) { throw new Error('Not implemented: registerComponent'); diff --git a/packages/lib/services/plugins/WebviewController.ts b/packages/lib/services/plugins/WebviewController.ts index 87e729854bf..5b94697c514 100644 --- a/packages/lib/services/plugins/WebviewController.ts +++ b/packages/lib/services/plugins/WebviewController.ts @@ -4,6 +4,7 @@ import { ButtonSpec, DialogResult, ViewHandle } from './api/types'; const { toSystemSlashes } = require('../../path-utils'); import PostMessageService, { MessageParticipant } from '../PostMessageService'; import { PluginViewState } from './reducer'; +import { defaultWindowId } from '../../reducer'; export enum ContainerType { Panel = 'panel', @@ -124,6 +125,7 @@ export default class WebviewController extends ViewController { void PostMessageService.instance().postMessage({ pluginId: this.pluginId, viewId: this.handle, + windowId: defaultWindowId, contentScriptId: null, from: MessageParticipant.Plugin, to: MessageParticipant.UserWebview, @@ -188,6 +190,11 @@ export default class WebviewController extends ViewController { // --------------------------------------------- public async open(): Promise { + if (this.closeResponse_) { + this.closeResponse_.resolve(null); + this.closeResponse_ = null; + } + this.store.dispatch({ type: 'VISIBLE_DIALOGS_ADD', name: this.handle, @@ -213,6 +220,7 @@ export default class WebviewController extends ViewController { public closeWithResponse(result: DialogResult) { this.close(); this.closeResponse_.resolve(result); + this.closeResponse_ = null; } public get buttons(): ButtonSpec[] { diff --git a/packages/lib/services/plugins/api/Joplin.ts b/packages/lib/services/plugins/api/Joplin.ts index 101d8257fee..5d868cf5ec3 100644 --- a/packages/lib/services/plugins/api/Joplin.ts +++ b/packages/lib/services/plugins/api/Joplin.ts @@ -57,7 +57,7 @@ export default class Joplin { this.settings_ = new JoplinSettings(plugin); this.contentScripts_ = new JoplinContentScripts(plugin); this.clipboard_ = new JoplinClipboard(implementation.clipboard, implementation.nativeImage); - this.window_ = new JoplinWindow(implementation.window, plugin, store); + this.window_ = new JoplinWindow(plugin, store); } public get data(): JoplinData { diff --git a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts index 371cd956048..650a2e627e4 100644 --- a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts +++ b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts @@ -110,7 +110,9 @@ export default class JoplinViewsDialogs { } /** - * Opens the dialog + * Opens the dialog. + * + * On desktop, this closes any copies of the dialog open in different windows. */ public async open(handle: ViewHandle): Promise { return this.controller(handle).open(); diff --git a/packages/lib/services/plugins/api/JoplinWindow.ts b/packages/lib/services/plugins/api/JoplinWindow.ts index 1908f871ea0..2c7f91a0ba4 100644 --- a/packages/lib/services/plugins/api/JoplinWindow.ts +++ b/packages/lib/services/plugins/api/JoplinWindow.ts @@ -3,21 +3,13 @@ import shim from '../../../shim'; import Plugin from '../Plugin'; -export interface Implementation { - injectCustomStyles(elementId: string, cssFilePath: string): Promise; -} - export default class JoplinWindow { - private plugin_: Plugin; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private store_: any; - private implementation_: Implementation; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public constructor(implementation: Implementation, plugin: Plugin, store: any) { - this.implementation_ = implementation; - this.plugin_ = plugin; + public constructor(_plugin: Plugin, store: any) { this.store_ = store; } @@ -30,7 +22,10 @@ export default class JoplinWindow { * desktop */ public async loadChromeCssFile(filePath: string) { - await this.implementation_.injectCustomStyles(`pluginStyles_${this.plugin_.id}`, filePath); + this.store_.dispatch({ + type: 'CUSTOM_CHROME_CSS_ADD', + filePath, + }); } /** @@ -45,7 +40,7 @@ export default class JoplinWindow { const cssString = await shim.fsDriver().readFile(filePath, 'utf8'); this.store_.dispatch({ - type: 'CUSTOM_CSS_APPEND', + type: 'CUSTOM_VIEWER_CSS_APPEND', css: cssString, }); } diff --git a/packages/lib/services/plugins/testing/MockPlatformImplementation.ts b/packages/lib/services/plugins/testing/MockPlatformImplementation.ts index 79207ebd55f..2985470c134 100644 --- a/packages/lib/services/plugins/testing/MockPlatformImplementation.ts +++ b/packages/lib/services/plugins/testing/MockPlatformImplementation.ts @@ -22,10 +22,6 @@ export default class MockPlatformImplementation extends BasePlatformImplementati return null; } - public override get window(): null { - return null; - } - public override get joplin() { return { views: { dialogs: { showMessageBox: jest.fn(), showOpenDialog: jest.fn() } } }; } diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 57efa993956..58c04363bb9 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -133,6 +133,7 @@ rnqc owasp tabindex Backblaze +runtimes onnx onnxruntime treeitem diff --git a/packages/tools/gulp/tasks/buildScriptIndexes.js b/packages/tools/gulp/tasks/buildScriptIndexes.js index 77c45f86bbf..f746d931e4c 100644 --- a/packages/tools/gulp/tasks/buildScriptIndexes.js +++ b/packages/tools/gulp/tasks/buildScriptIndexes.js @@ -62,7 +62,7 @@ module.exports = { src: '', fn: async function() { await processDirectory(`${rootDir}/packages/app-desktop/commands`); - await processDirectory(`${rootDir}/packages/app-desktop/gui/MainScreen/commands`); + await processDirectory(`${rootDir}/packages/app-desktop/gui/WindowCommandsAndDialogs/commands`); await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteEditor/commands`); await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteList/commands`); await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteListControls/commands`); diff --git a/readme/dev/spec/background_windows.md b/readme/dev/spec/background_windows.md new file mode 100644 index 00000000000..9c0b8d3e612 --- /dev/null +++ b/readme/dev/spec/background_windows.md @@ -0,0 +1,163 @@ +# Background windows + +This document explains Joplin's multiple window support from a technical perspective. + +## Reducer state + +On desktop, Joplin supports multiple windows with the `backgroundWindows` reducer state. Each entry in `backgroundWindows` contains the state of a window that is in the background (not currently focused). The currently focused window's state is stored in the top-level state object. + +### State structure + +Suppose that there are three windows, `window1`, `window2`, and `window3`. + +Initially, `window1` is focused and the state might look like this: +```js +state = { + backgroundWindows: { + window2: { + windowId: 'window2', + selectedNoteIds: ['id-of-the-note-selected-in-window2'], + // ... other per-window properties ... + }, + window3: { + windowId: 'window3', + selectedNoteIds: ['id-of-the-note-selected-in-window3'], + // ... other per-window properties ... + }, + }, + + // + windowId: 'window1', + selectedNoteIds: ['id-of-the-note-selected-in-the-main-window'], + + // ... other per-window properties ... + // + + // + settings: {...}, + profileConfig: {...}, + syncStatus: {...}, + // ... all shared properties ... + // +} +``` + +Notice that the state for the focused window is part of the main state object. This is for compatibility. Historically, Joplin only supported one window with at most one open editor. As a result, many commands assume that if `state.selectedNoteIds = ['some-id-here']`, then `state.selectedNoteIds[0]` is the ID of the note currently being edited. By storing the `selectedNoteIds` for the currently active window in the main `state` object, Joplin remains compatible with legacy commands and components. + +### Switching windows + +When the user switches from one window to another, a `WINDOW_FOCUS` action is sent to the reducer. The reducer responds by moving the old window's state to `backgroundWindows` and the new window's state to the main `state` object. + +For example, if a user switches from `window1` to `window2`, the state change looks like this: +1. Initial state: `window2` is the only background window and `window1` is the foreground window. + ```js + state = { + backgroundWindows: { + window2: { + windowId: 'window2', + selectedNoteIds: ['note-in-window2'], + // ... other per-window properties ... + }, + }, + + //
+ windowId: 'window1', + selectedNoteIds: ['note-in-window1'], + + // ... other per-window properties ... + //
+ + // + // ... all shared properties ... + // + } + ``` +2. The reducer receives the `WINDOW_FOCUS` action and swaps which window state is in `backgroundWindows`. notice that only the window state is moved (the shared state is unmodified): + ```js + state = { + backgroundWindows: { + window1: { + windowId: 'window1', + selectedNoteIds: ['note-in-window1'], + // ... other per-window properties ... + }, + }, + + //
+ windowId: 'window2', + selectedNoteIds: ['note-in-window2'], + + // ... other per-window properties ... + //
+ + // + // ... all shared properties ... + // + } + ``` + Window 2 is now the main window. + +### Extra per-window state + +Some per-window state is application specific. For example, the `devToolsVisible` property is **specific to the desktop app** and is different for each window (because each window can have its own dev tools). + +This is supported by giving `backgroundWindows` a type with an extended set of properties in the desktop application's state. + +When new windows are opened with the `WINDOW_OPEN` action, the default value for the new window-specific properties must be provided. For example, `{ devToolsVisible: false }`. + +## Rendering components in new windows + +The [react-dom portals API](https://react.dev/reference/react-dom/createPortal) allows a component to render its children in a different place. In this case, Joplin uses the portals API to render certain components in a new window, while keeping them in the same React component tree. + +### Same React tree, different DOMs + +The React portals API allows all editors to be descendants of the `` component, while also being in a different HTML `document`: + +```mermaid +flowchart TD + subgraph Main Window + Root + Root-->Navigator + Navigator-->MainContent("[[Main app content]]") + Root-->EditorWindow1 + Root-->EditorWindow2 + + EditorWindow1-->NewWindowOrIframe1-->Portal1(("Portal")) + EditorWindow2-->NewWindowOrIframe2-->Portal2(("Portal")) + end + + subgraph Window1 + NoteEditor1["Note editor"] + end + + subgraph Window2 + NoteEditor2["Note editor"] + end + + Portal1-->NoteEditor1 + Portal2-->NoteEditor2 +``` + +This also means that components running in secondary windows (e.g. `Window1`, `Window2` above) are controlled by JavaScript in the main window. As a result, +- Closing the main window means that secondary windows must also be closed. + - Hiding the window with `BrowserWindow.hide` is used instead. +- Component logic must be careful to reference the correct DOM. **Using the global `document` and `window` objects is often incorrect.** + - Instead of the `document` global variable, use [`HTMLElement.ownerDocument`](https://developer.mozilla.org/en-US/docs/Web/API/Node/ownerDocument) (or the `useDom` hook). + - Instead of `window`, use [`.defaultView`](https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView) on a [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document) object. + +### Getting the ID for the current window + +Sometimes, it's necessary to get the Joplin ID of the current window. This ID allows referencing the state specific to a window in Joplin's reducer. + +Suppose `` could be displayed in any window of the app and we want to find the selected note IDs for its window. To do this: +1. Get the window ID. This can be done by either: + - Passing it as a `prop` + - With `useContext(WindowIdContext)`, where `WindowIdContext` is the context that provides the window ID. ([Documentation for `useContext`](https://react.dev/reference/react/useContext)). +2. Use the window ID to determine the current window state. To do this, use the window ID to look up the state for the current window (e.g. with `stateUtils.windowStateById`). +3. Get the `selectedNoteIds` from the window state. + +## Limitations + +- **All windows have the same menubar:** Currently, Joplin sets the application menubar with `Menu.setApplicationMenu`. This allows the menu to be updated later with `Menu.getApplicationMenu`, but means that all windows have the same menu. + - An alternative might be to set menus on individual windows with [`BrowserWindow.setMenu`](https://www.electronjs.org/docs/latest/api/browser-window#winsetmenumenu-linux-windows), but this doesn't allow accessing the menu later (e.g. from Playwright) with `Menu.getApplicationMenu` and does not work on MacOS. +- **Certain UI only available on the main window:** At present, certain parts of the UI (e.g. sidebars, warning banners, etc.) are only visible on the main window.While this simplifies multi-window support by limiting which components need to work in secondary windows, it makes secondary windows more limited than primary windows.