From 608dbab4533bb7a8fc6dca3dc19ff0125a402b51 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Fri, 6 Jun 2025 02:00:47 -0700 Subject: [PATCH] Desktop: Resolves #11687: Plugins: Allow editor plugins to support multiple windows (#12041) --- .eslintignore | 6 +- .gitignore | 6 +- .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 38 ++-- .../app-desktop/gui/NoteEditor/utils/types.ts | 1 - .../utils/useConnectToEditorPlugin.ts | 106 ++++++++++++ .../utils/usePluginEditorView.test.ts | 18 +- .../NoteEditor/utils/usePluginEditorView.ts | 10 +- .../gui/NoteToolbar/NoteToolbar.tsx | 2 +- .../models/NoteEditorScreen.ts | 4 + .../integration-tests/pluginApi.spec.ts | 62 +++++++ .../test-plugins/editorPluginSaving.js | 66 +++++++ .../test-plugins/multipleEditorPlugins.js | 46 +++++ .../integration-tests/util/test.ts | 10 +- .../hooks/useWebviewToPluginMessages.ts | 10 +- .../plugins/dialogs/PluginDialogWebView.tsx | 2 +- .../plugins/dialogs/PluginUserWebView.tsx | 2 +- .../plugins/utils/usePluginItem.ts | 2 +- .../components/screens/Note/Note.tsx | 50 ++++-- .../app/templates/api/JoplinCommands.d.ts | 7 +- .../app/templates/api/JoplinSettings.d.ts | 6 +- .../app/templates/api/JoplinViews.d.ts | 13 +- .../app/templates/api/JoplinViewsEditor.d.ts | 31 +++- .../app/templates/api/JoplinViewsPanels.d.ts | 4 + .../app/templates/api/JoplinWorkspace.d.ts | 9 + .../generators/app/templates/api/types.ts | 51 +++++- packages/lib/commands/showEditorPlugin.ts | 63 ++++--- packages/lib/commands/toggleEditorPlugin.ts | 39 +++-- .../shared/reduxSharedMiddleware.ts | 12 +- packages/lib/eventManager.ts | 12 ++ packages/lib/hooks/{ => plugins}/usePlugin.ts | 4 +- .../plugins/useVisiblePluginEditorViewIds.ts | 13 ++ .../commands/stateToWhenClauseContext.ts | 2 +- .../services/plugins/EditorPluginHandler.ts | 95 ++++++++-- packages/lib/services/plugins/Plugin.ts | 8 + .../lib/services/plugins/WebviewController.ts | 82 ++++++--- .../plugins/api/JoplinViewsDialogs.ts | 2 +- .../services/plugins/api/JoplinViewsEditor.ts | 162 ++++++++++++++++-- .../services/plugins/api/JoplinViewsPanels.ts | 7 +- .../services/plugins/api/JoplinWorkspace.ts | 2 + packages/lib/services/plugins/api/types.ts | 37 +++- packages/lib/services/plugins/reducer.ts | 34 +++- .../utils/getActivePluginEditorView.ts | 32 ++-- .../utils/getActivePluginEditorViews.ts | 27 +++ .../plugins/utils/getShownPluginEditorView.ts | 12 +- .../utils/getShownPluginEditorViewIds.ts | 8 + packages/lib/tsconfig.json | 2 +- 46 files changed, 1022 insertions(+), 195 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.ts create mode 100644 packages/app-desktop/integration-tests/resources/test-plugins/editorPluginSaving.js create mode 100644 packages/app-desktop/integration-tests/resources/test-plugins/multipleEditorPlugins.js rename packages/lib/hooks/{ => plugins}/usePlugin.ts (92%) create mode 100644 packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.ts create mode 100644 packages/lib/services/plugins/utils/getActivePluginEditorViews.ts create mode 100644 packages/lib/services/plugins/utils/getShownPluginEditorViewIds.ts diff --git a/.eslintignore b/.eslintignore index b5d5803be8..2cdcfe6fd5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -297,6 +297,7 @@ packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.test.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.js packages/app-desktop/gui/NoteEditor/utils/types.js +packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.js packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js packages/app-desktop/gui/NoteEditor/utils/useFolder.js @@ -1117,12 +1118,13 @@ packages/lib/fsDriver.test.js packages/lib/geolocation-node.js packages/lib/getAppName.test.js packages/lib/getAppName.js +packages/lib/hooks/plugins/usePlugin.js +packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.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 packages/lib/hooks/useQueuedAsyncEffect.js @@ -1363,12 +1365,14 @@ packages/lib/services/plugins/testing/MockPluginRunner.js packages/lib/services/plugins/utils/createViewHandle.js packages/lib/services/plugins/utils/executeSandboxCall.js packages/lib/services/plugins/utils/getActivePluginEditorView.js +packages/lib/services/plugins/utils/getActivePluginEditorViews.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.js packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js packages/lib/services/plugins/utils/getPluginSettingValue.js packages/lib/services/plugins/utils/getShownPluginEditorView.js +packages/lib/services/plugins/utils/getShownPluginEditorViewIds.js packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js packages/lib/services/plugins/utils/isCompatible/index.test.js packages/lib/services/plugins/utils/isCompatible/index.js diff --git a/.gitignore b/.gitignore index b09de2a149..18dd672d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -271,6 +271,7 @@ packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.test.js packages/app-desktop/gui/NoteEditor/utils/resourceHandling.js packages/app-desktop/gui/NoteEditor/utils/types.js +packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.js packages/app-desktop/gui/NoteEditor/utils/useDropHandler.js packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js packages/app-desktop/gui/NoteEditor/utils/useFolder.js @@ -1091,12 +1092,13 @@ packages/lib/fsDriver.test.js packages/lib/geolocation-node.js packages/lib/getAppName.test.js packages/lib/getAppName.js +packages/lib/hooks/plugins/usePlugin.js +packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.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 packages/lib/hooks/useQueuedAsyncEffect.js @@ -1337,12 +1339,14 @@ packages/lib/services/plugins/testing/MockPluginRunner.js packages/lib/services/plugins/utils/createViewHandle.js packages/lib/services/plugins/utils/executeSandboxCall.js packages/lib/services/plugins/utils/getActivePluginEditorView.js +packages/lib/services/plugins/utils/getActivePluginEditorViews.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.js packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js packages/lib/services/plugins/utils/getPluginSettingValue.js packages/lib/services/plugins/utils/getShownPluginEditorView.js +packages/lib/services/plugins/utils/getShownPluginEditorViewIds.js packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js packages/lib/services/plugins/utils/isCompatible/index.test.js packages/lib/services/plugins/utils/isCompatible/index.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 94ca71942f..9a0cb63ba8 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -52,10 +52,10 @@ import Logger from '@joplin/utils/Logger'; import usePluginEditorView from './utils/usePluginEditorView'; import { stateUtils } from '@joplin/lib/reducer'; import { WindowIdContext } from '../NewWindowOrIFrame'; -import PluginService from '@joplin/lib/services/plugins/PluginService'; -import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler'; import useResourceUnwatcher from './utils/useResourceUnwatcher'; import StatusBar from './StatusBar'; +import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds'; +import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin'; const debounce = require('debounce'); @@ -80,12 +80,6 @@ function NoteEditorContent(props: NoteEditorProps) { const isMountedRef = useRef(true); const noteSearchBarRef = useRef(null); - const editorPluginHandler = useMemo(() => { - return new EditorPluginHandler(PluginService.instance()); - }, []); - - const shownEditorViewIds = props['plugins.shownEditorViewIds']; - // Should be constant and unique to this instance of the editor. const editorId = useMemo(() => { return `editor-${editorIdCounter++}`; @@ -105,18 +99,7 @@ function NoteEditorContent(props: NoteEditorProps) { }, []); const effectiveNoteId = useEffectiveNoteId(props); - - useAsyncEffect(async (_event) => { - if (!props.startupPluginsLoaded) return; - await editorPluginHandler.emitActivationCheck(); - }, [effectiveNoteId, editorPluginHandler, props.startupPluginsLoaded]); - - useEffect(() => { - if (!props.startupPluginsLoaded) return; - editorPluginHandler.emitUpdate(shownEditorViewIds); - }, [effectiveNoteId, editorPluginHandler, shownEditorViewIds, props.startupPluginsLoaded]); - - const { editorPlugin, editorView } = usePluginEditorView(props.plugins, shownEditorViewIds); + const { editorPlugin, editorView } = usePluginEditorView(props.plugins); const builtInEditorVisible = !editorPlugin; const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ @@ -135,6 +118,19 @@ function NoteEditorContent(props: NoteEditorProps) { const formNoteFolder = useFolder({ folderId: formNote.parent_id }); + const windowId = useContext(WindowIdContext); + const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId); + useConnectToEditorPlugin({ + startupPluginsLoaded: props.startupPluginsLoaded, + setFormNote, + scheduleSaveNote, + formNote, + effectiveNoteId, + shownEditorViewIds, + activeEditorView: editorView, + plugins: props.plugins, + }); + const { localSearch, onChange: localSearch_change, @@ -336,7 +332,6 @@ function NoteEditorContent(props: NoteEditorProps) { lastEditorScrollPercents: props.lastEditorScrollPercents, editorRef, }); - const windowId = useContext(WindowIdContext); const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml); useResourceUnwatcher({ noteId: formNote.id, windowId }); @@ -706,7 +701,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { highlightedWords: state.highlightedWords, plugins: state.pluginService.plugins, pluginHtmlContents: state.pluginService.pluginHtmlContents, - 'plugins.shownEditorViewIds': state.settings['plugins.shownEditorViewIds'] || [], toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([ 'historyBackward', 'historyForward', diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index a085bbd295..a58efea9be 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -63,7 +63,6 @@ export interface NoteEditorProps { syncUserId: string; searchResults: ProcessResultsRow[]; pluginHtmlContents: PluginHtmlContents; - 'plugins.shownEditorViewIds': string[]; onTitleChange?: (title: string)=> void; bodyEditor: string; startupPluginsLoaded: boolean; diff --git a/packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.ts b/packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.ts new file mode 100644 index 0000000000..2b17477d3d --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/useConnectToEditorPlugin.ts @@ -0,0 +1,106 @@ +import EditorPluginHandler, { OnSaveNoteCallback } from '@joplin/lib/services/plugins/EditorPluginHandler'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +import { useContext, useEffect, useMemo, useRef } from 'react'; +import { FormNote } from './types'; +import { WindowIdContext } from '../../NewWindowOrIFrame'; +import Logger from '@joplin/utils/Logger'; +import { PluginEditorViewState, PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; +import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect'; +import { OnSetFormNote } from './useFormNote'; + +const logger = Logger.create('useEditorPlugin'); + +type OnScheduleSaveNote = (formNote: FormNote)=> Promise; + +interface Props { + plugins: PluginStates; + startupPluginsLoaded: boolean; + formNote: FormNote; + activeEditorView: PluginEditorViewState; + setFormNote: OnSetFormNote; + scheduleSaveNote: OnScheduleSaveNote; + effectiveNoteId: string; + shownEditorViewIds: string[]; +} + +const useEditorPluginHandler = (formNote: FormNote, setFormNote: OnSetFormNote, scheduleSaveNote: OnScheduleSaveNote) => { + const formNoteRef = useRef(formNote); + formNoteRef.current = formNote; + const lastEditorPluginSaveRef = useRef(null); + + return useMemo(() => { + const onSave: OnSaveNoteCallback = async (newContent) => { + const changed = newContent.body !== formNoteRef.current.body || newContent.id !== formNoteRef.current.id; + if (!changed) return; + + const differentId = newContent.id !== formNoteRef.current.id; + const sameIdAsLastSave = newContent.id === lastEditorPluginSaveRef.current?.id; + // Ensure that the note is being saved with the correct parent_id, title, etc. + const sourceFormNote = differentId && sameIdAsLastSave ? lastEditorPluginSaveRef.current : formNoteRef.current; + const newFormNote = { + ...sourceFormNote, + id: newContent.id, + body: newContent.body, + hasChanged: true, + }; + + lastEditorPluginSaveRef.current = newFormNote; + setFormNote(newFormNote); + return scheduleSaveNote(newFormNote); + }; + return new EditorPluginHandler(PluginService.instance(), onSave); + }, [setFormNote, scheduleSaveNote]); +}; + +const useLoadedViewIdsCacheKey = (windowId: string, plugins: PluginStates) => { + return useMemo(() => { + const viewIds = []; + for (const plugin of Object.values(plugins)) { + for (const view of Object.values(plugin.views)) { + if (view.containerType === ContainerType.Editor && view.parentWindowId === windowId) { + viewIds.push(view.id); + } + } + } + // Create a string that can be easily checked for changes as an effect dependency + return JSON.stringify(viewIds); + }, [windowId, plugins]); +}; + +// Connects editor plugins to the current editor (handles editor plugin saving, loading). +const useConnectToEditorPlugin = ({ + plugins, startupPluginsLoaded, setFormNote, formNote, scheduleSaveNote, effectiveNoteId, activeEditorView, shownEditorViewIds, +}: Props) => { + const windowId = useContext(WindowIdContext); + const loadedViewIdCacheKey = useLoadedViewIdsCacheKey(windowId, plugins); + const editorPluginHandler = useEditorPluginHandler(formNote, setFormNote, scheduleSaveNote); + + useQueuedAsyncEffect(async () => { + if (!startupPluginsLoaded) return; + logger.debug('Emitting activation check for views:', loadedViewIdCacheKey); + + await editorPluginHandler.emitActivationCheck({ + parentWindowId: windowId, + noteId: effectiveNoteId, + }); + // It's important to re-run the activation check when the loaded view IDs change. + // As such, `loadedViewIds` needs to be in the dependencies list: + }, [loadedViewIdCacheKey, windowId, effectiveNoteId, editorPluginHandler, startupPluginsLoaded]); + + useEffect(() => { + if (activeEditorView) { + editorPluginHandler.onEditorPluginShown(activeEditorView.id); + } + }, [activeEditorView, editorPluginHandler]); + + const formNoteBody = formNote.body; + useEffect(() => { + editorPluginHandler.emitUpdate({ + noteId: effectiveNoteId, + newBody: formNoteBody, + }, shownEditorViewIds); + }, [effectiveNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]); +}; + +export default useConnectToEditorPlugin; diff --git a/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts index d40b0d327a..bf01ca7a38 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts @@ -1,16 +1,20 @@ import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import usePluginEditorView from './usePluginEditorView'; -import { PluginStates, PluginViewState } from '@joplin/lib/services/plugins/reducer'; +import { PluginEditorViewState, PluginStates } from '@joplin/lib/services/plugins/reducer'; import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; +import { defaultWindowId } from '@joplin/lib/reducer'; -const sampleView = (): PluginViewState => { +const sampleView = (): PluginEditorViewState => { return { buttons: [], containerType: ContainerType.Editor, id: 'view-1', - opened: true, + editorTypeId: 'view-1', type: 'webview', + opened: true, + active: true, + parentWindowId: defaultWindowId, }; }; @@ -24,7 +28,7 @@ describe('usePluginEditorView', () => { const pluginStates: PluginStates = { '0': { contentScripts: {}, - id: '1', + id: '0', views: { 'view-0': { ...sampleView(), @@ -43,7 +47,7 @@ describe('usePluginEditorView', () => { }; { - const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + const test = renderHook(() => usePluginEditorView(pluginStates)); expect(test.result.current.editorPlugin.id).toBe('1'); expect(test.result.current.editorView.id).toBe('view-1'); test.unmount(); @@ -51,7 +55,7 @@ describe('usePluginEditorView', () => { { pluginStates['1'].views['view-1'].opened = false; - const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + const test = renderHook(() => usePluginEditorView(pluginStates)); expect(test.result.current.editorPlugin).toBeFalsy(); test.unmount(); } @@ -79,7 +83,7 @@ describe('usePluginEditorView', () => { }; { - const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + const test = renderHook(() => usePluginEditorView(pluginStates)); expect(test.result.current.editorPlugin.id).toBe('1'); expect(test.result.current.editorView.id).toBe('view-1'); test.unmount(); diff --git a/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.ts b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.ts index fa86bf0a9e..96be3fd33d 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.ts @@ -1,11 +1,13 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import getShownPluginEditorView from '@joplin/lib/services/plugins/utils/getShownPluginEditorView'; +import { WindowIdContext } from '../../NewWindowOrIFrame'; // If a plugin editor should be shown for the current note, this function will return the plugin and // associated view. -export default (plugins: PluginStates, shownEditorViewIds: string[]) => { +export default (plugins: PluginStates) => { + const windowId = useContext(WindowIdContext); return useMemo(() => { - return getShownPluginEditorView(plugins, shownEditorViewIds); - }, [plugins, shownEditorViewIds]); + return getShownPluginEditorView(plugins, windowId); + }, [plugins, windowId]); }; diff --git a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx index 4f46657e37..ce39e06693 100644 --- a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx +++ b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx @@ -51,7 +51,7 @@ interface ConnectProps { const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); - const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins); + const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins, ownProps.windowId); const commands = [ 'showSpellCheckerMenu', diff --git a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts index fed766a16a..4d3ceb09e9 100644 --- a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts +++ b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts @@ -6,6 +6,7 @@ import EditorCodeDialog from './EditorCodeDialog'; export default class NoteEditorPage { public readonly codeMirrorEditor: Locator; public readonly noteViewerContainer: Locator; + public readonly editorPluginFrame: Locator; public readonly richTextEditor: Locator; public readonly noteTitleInput: Locator; @@ -16,6 +17,7 @@ export default class NoteEditorPage { public readonly toggleEditorsButton: Locator; public readonly toggleEditorLayoutButton: Locator; private readonly disableTabNavigationButton: Locator; + public readonly toggleEditorPluginButton: Locator; public readonly editorSearchInput: Locator; public readonly viewerSearchInput: Locator; @@ -26,6 +28,7 @@ export default class NoteEditorPage { this.containerLocator = page.locator('.rli-editor'); this.codeMirrorEditor = this.containerLocator.locator('.cm-editor'); this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]'); + this.editorPluginFrame = this.containerLocator.locator('iframe[id^="plugin-view-"]'); this.noteTitleInput = this.containerLocator.locator('.title-input'); this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' }); this.toggleCodeBlockButton = this.containerLocator.getByRole('button', { name: 'Code Block' }); @@ -36,6 +39,7 @@ export default class NoteEditorPage { this.editorSearchInput = this.containerLocator.getByPlaceholder('Find'); this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...'); this.disableTabNavigationButton = this.containerLocator.getByRole('button', { name: 'Tab moves focus' }); + this.toggleEditorPluginButton = this.containerLocator.getByRole('button', { name: 'Toggle editor plugin' }); this.richTextCodeEditor = new EditorCodeDialog(page); } diff --git a/packages/app-desktop/integration-tests/pluginApi.spec.ts b/packages/app-desktop/integration-tests/pluginApi.spec.ts index adbaaa5027..00559a0380 100644 --- a/packages/app-desktop/integration-tests/pluginApi.spec.ts +++ b/packages/app-desktop/integration-tests/pluginApi.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from './util/test'; import MainScreen from './models/MainScreen'; +import { msleep, Second } from '@joplin/utils/time'; test.describe('pluginApi', () => { test('the editor.setText command should update the current note (use RTE: false)', async ({ startAppWithPlugins }) => { @@ -60,5 +61,66 @@ test.describe('pluginApi', () => { await mainScreen.goToAnything.runCommand(app, 'testShowToastNotification'); await expect(notificationLocator).toHaveCount(3); }); + + test('should be possible to switch between multiple editor plugins', async ({ startAppWithPlugins }) => { + const { mainWindow } = await startAppWithPlugins(['resources/test-plugins/multipleEditorPlugins.js']); + const mainScreen = await new MainScreen(mainWindow).setup(); + + await mainScreen.createNewNote('Test note'); + const toggleButton = mainScreen.noteEditor.toggleEditorPluginButton; + + // Initially, the toggle button should be visible, and so should the default editor. + await expect(mainScreen.noteEditor.codeMirrorEditor).toBeAttached(); + await toggleButton.click(); + + const pluginFrame = mainScreen.noteEditor.editorPluginFrame; + await expect(pluginFrame).toBeAttached(); + + // Should describe the frame + const frameViewIdLabel = pluginFrame.contentFrame().locator('#view-id-base'); + await expect(frameViewIdLabel).toHaveText('test-editor-plugin-1'); + + // Clicking toggle again should cycle to the next editor plugin + await toggleButton.click(); + await expect(frameViewIdLabel).toHaveText('test-editor-plugin-2'); + + // Clicking toggle again should dismiss the editor plugins + await toggleButton.click(); + await expect(mainScreen.noteEditor.codeMirrorEditor).toBeAttached(); + await expect(pluginFrame).not.toBeAttached(); + }); + + test('should be possible to save changes to a note using the editor plugin API', async ({ startAppWithPlugins }) => { + const { mainWindow, app } = await startAppWithPlugins(['resources/test-plugins/editorPluginSaving.js']); + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.createNewNote('Test note'); + + const noteEditor = mainScreen.noteEditor; + await noteEditor.focusCodeMirrorEditor(); + await mainWindow.keyboard.type('Initial content.'); + + const toggleButton = noteEditor.toggleEditorPluginButton; + await toggleButton.click(); + + // Should switch to the editor + const pluginFrame = noteEditor.editorPluginFrame; + await expect(pluginFrame).toBeAttached(); + const pluginFrameContent = pluginFrame.contentFrame(); + await expect(pluginFrameContent.getByText('Loaded!')).toBeAttached(); + + // Editor plugin tests should pass + await mainScreen.goToAnything.runCommand(app, 'testEditorPluginSave-test-editor-plugin'); + + // Should have saved + await toggleButton.click(); + const expectedUpdatedText = 'Changed by test-editor-plugin'; + await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText); + + // Regression test: Historically the editor's content would very briefly be correct, then + // almost immediately be replaced with the old content. Doing another check after a brief + // delay should cause the test to fail if this bug returns: + await msleep(Second); + await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText); + }); }); diff --git a/packages/app-desktop/integration-tests/resources/test-plugins/editorPluginSaving.js b/packages/app-desktop/integration-tests/resources/test-plugins/editorPluginSaving.js new file mode 100644 index 0000000000..7218f37707 --- /dev/null +++ b/packages/app-desktop/integration-tests/resources/test-plugins/editorPluginSaving.js @@ -0,0 +1,66 @@ +// Allows referencing the Joplin global: +/* eslint-disable no-undef */ + +// Allows the `joplin-manifest` block comment: +/* eslint-disable multiline-comment-style */ + +/* joplin-manifest: +{ + "id": "org.joplinapp.plugins.example.editorPlugin", + "manifest_version": 1, + "app_min_version": "3.1", + "name": "JS Bundle test", + "description": "JS Bundle Test plugin", + "version": "1.0.0", + "author": "", + "homepage_url": "https://joplinapp.org" +} +*/ + +const registerEditorPlugin = async (editorViewId) => { + const editors = joplin.views.editors; + const saveCallbacks = []; + + await editors.register(editorViewId, { + onSetup: async (viewHandle) => { + await editors.setHtml( + viewHandle, + 'Loaded!', + ); + + let noteId; + await editors.onUpdate(viewHandle, event => { + noteId = event.noteId; + }); + + saveCallbacks.push(() => { + void editors.saveNote(viewHandle, { + noteId, + body: `Changed by ${editorViewId}`, + }); + }); + }, + + onActivationCheck: async _event => { + // Always enable + return true; + }, + }); + + await joplin.commands.register({ + name: `testEditorPluginSave-${editorViewId}`, + label: `Test editor plugin save for ${editorViewId}`, + iconName: 'fas fa-music', + execute: async () => { + for (const saveCallback of saveCallbacks) { + saveCallback(); + } + }, + }); +}; + +joplin.plugins.register({ + onStart: async function() { + await registerEditorPlugin('test-editor-plugin'); + }, +}); diff --git a/packages/app-desktop/integration-tests/resources/test-plugins/multipleEditorPlugins.js b/packages/app-desktop/integration-tests/resources/test-plugins/multipleEditorPlugins.js new file mode 100644 index 0000000000..22e28cf14e --- /dev/null +++ b/packages/app-desktop/integration-tests/resources/test-plugins/multipleEditorPlugins.js @@ -0,0 +1,46 @@ +// Allows referencing the Joplin global: +/* eslint-disable no-undef */ + +// Allows the `joplin-manifest` block comment: +/* eslint-disable multiline-comment-style */ + +/* joplin-manifest: +{ + "id": "org.joplinapp.plugins.example.editorPlugin", + "manifest_version": 1, + "app_min_version": "3.1", + "name": "JS Bundle test", + "description": "JS Bundle Test plugin", + "version": "1.0.0", + "author": "", + "homepage_url": "https://joplinapp.org" +} +*/ + +const registerEditorPlugin = async (editorViewId) => { + const editors = joplin.views.editors; + await editors.register(editorViewId, { + async onSetup(view) { + await editors.setHtml( + view, + `
+ Editor plugin: + ${editorViewId} + for view handle: ${encodeURI(view)} +
`, + ); + }, + async onActivationCheck(_event) { + // Always enable + return true; + }, + }); +}; + +joplin.plugins.register({ + onStart: async function() { + // Register two different editor plugins: + await registerEditorPlugin('test-editor-plugin-1'); + await registerEditorPlugin('test-editor-plugin-2'); + }, +}); diff --git a/packages/app-desktop/integration-tests/util/test.ts b/packages/app-desktop/integration-tests/util/test.ts index 79e77d8566..7ad31cc0e8 100644 --- a/packages/app-desktop/integration-tests/util/test.ts +++ b/packages/app-desktop/integration-tests/util/test.ts @@ -70,17 +70,20 @@ export const test = base.extend({ // See https://github.com/microsoft/playwright/issues/8798 // // eslint-disable-next-line no-empty-pattern - profileDirectory: async ({ }, use) => { + profileDirectory: async ({ }, use, testInfo) => { const profilePath = resolve(join(testDir, 'test-profile')); const profileSubdir = join(profilePath, uuid.createNano()); await mkdirp(profileSubdir); await use(profileSubdir); + // For debugging purposes, attach the Joplin log file to the test: + await attachJoplinLog(profileSubdir, testInfo); + await remove(profileSubdir); }, - electronApp: async ({ profileDirectory }, use, testInfo) => { + electronApp: async ({ profileDirectory }, use) => { const startupArgs = createStartupArgs(profileDirectory); const electronApp = await electron.launch({ args: startupArgs }); const startupPromise = waitForAppLoaded(electronApp); @@ -89,9 +92,6 @@ export const test = base.extend({ await use(electronApp); - // For debugging purposes, attach the Joplin log file to the test: - await attachJoplinLog(profileDirectory, testInfo); - await electronApp.firstWindow(); await electronApp.close(); }, diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts index 41d1b5d2f8..0c91ec0755 100644 --- a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts +++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts @@ -25,9 +25,13 @@ export default function(webviewRef: RefObject, isReady: boole } if (event.data.target === 'postMessageService.registerViewMessageHandler') { - PostMessageService.instance().registerViewMessageHandler(ResponderComponentType.UserWebview, viewId, (message: MessageResponse) => { - postMessage('postMessageService.plugin_message', { message }); - }); + PostMessageService.instance().registerViewMessageHandler( + ResponderComponentType.UserWebview, + viewId, + (message: MessageResponse) => { + postMessage('postMessageService.plugin_message', { message }); + }, + ); } else if (event.data.target === 'postMessageService.message') { void PostMessageService.instance().postMessage({ pluginId, diff --git a/packages/app-mobile/components/plugins/dialogs/PluginDialogWebView.tsx b/packages/app-mobile/components/plugins/dialogs/PluginDialogWebView.tsx index 4391df2ed1..8368787f1d 100644 --- a/packages/app-mobile/components/plugins/dialogs/PluginDialogWebView.tsx +++ b/packages/app-mobile/components/plugins/dialogs/PluginDialogWebView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useCallback, useMemo, useState } from 'react'; import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer'; import { StyleSheet, View, useWindowDimensions } from 'react-native'; -import usePlugin from '@joplin/lib/hooks/usePlugin'; +import usePlugin from '@joplin/lib/hooks/plugins/usePlugin'; import { DialogContentSize, DialogWebViewApi } from '../types'; import { Button } from 'react-native-paper'; import { themeStyle } from '@joplin/lib/theme'; diff --git a/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx b/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx index d6fe14bcc1..95f2e35503 100644 --- a/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx +++ b/packages/app-mobile/components/plugins/dialogs/PluginUserWebView.tsx @@ -4,7 +4,7 @@ import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reduc import ExtendedWebView from '../../ExtendedWebView'; import { WebViewControl } from '../../ExtendedWebView/types'; import { ViewStyle } from 'react-native'; -import usePlugin from '@joplin/lib/hooks/usePlugin'; +import usePlugin from '@joplin/lib/hooks/plugins/usePlugin'; import shim from '@joplin/lib/shim'; import useDialogMessenger from './hooks/useDialogMessenger'; import useWebViewSetup from './hooks/useWebViewSetup'; diff --git a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts index 5896eb2d2b..84422fa4f1 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts +++ b/packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.ts @@ -2,7 +2,7 @@ import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; import { useMemo, useRef } from 'react'; -import usePlugin from '@joplin/lib/hooks/usePlugin'; +import usePlugin from '@joplin/lib/hooks/plugins/usePlugin'; // initialItem is used when the plugin is not installed. For example, if the plugin item is being // created from search results. diff --git a/packages/app-mobile/components/screens/Note/Note.tsx b/packages/app-mobile/components/screens/Note/Note.tsx index 8b1c170e29..5555ab6df0 100644 --- a/packages/app-mobile/components/screens/Note/Note.tsx +++ b/packages/app-mobile/components/screens/Note/Note.tsx @@ -68,6 +68,8 @@ import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getAct import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler'; import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner'; import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner'; +import { defaultWindowId } from '@joplin/lib/reducer'; +import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const emptyArray: any[] = []; @@ -86,6 +88,7 @@ interface NoteNavigation { } interface Props extends BaseProps { + windowId: string; provisionalNoteIds: string[]; navigation: NoteNavigation; dispatch: Dispatch; @@ -102,13 +105,13 @@ interface Props extends BaseProps { highlightedWords: string[]; noteHash: string; toolbarEnabled: boolean; - 'plugins.shownEditorViewIds': string[]; pluginHtmlContents: PluginHtmlContents; editorNoteReloadTimeRequest: number; } interface ComponentProps extends Props { dialogs: DialogControl; + visibleEditorPluginIds: string[]; } interface State { @@ -170,7 +173,9 @@ class NoteScreenComponent extends BaseScreenComponent imp // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public dialogbox: any; private commandRegistration_: RegisteredRuntime|null = null; - private editorPluginHandler_ = new EditorPluginHandler(PluginService.instance()); + private editorPluginHandler_ = new EditorPluginHandler(PluginService.instance(), saveEvent => { + return shared.noteComponent_change(this, 'body', saveEvent.body); + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public static navigationOptions(): any { @@ -561,10 +566,13 @@ class NoteScreenComponent extends BaseScreenComponent imp }, 100); } - await this.editorPluginHandler_.emitActivationCheck(); + await this.editorPluginHandler_.emitActivationCheck({ + noteId: this.props.noteId, + parentWindowId: defaultWindowId, + }); setTimeout(() => { - this.editorPluginHandler_.emitUpdate(this.props['plugins.shownEditorViewIds']); + this.emitEditorPluginUpdate_(); }, 300); } @@ -578,7 +586,7 @@ class NoteScreenComponent extends BaseScreenComponent imp await ResourceFetcher.instance().markForDownload(resourceIds); } - public componentDidUpdate(prevProps: Props, prevState: State) { + public componentDidUpdate(prevProps: ComponentProps, prevState: State) { if (this.doFocusUpdate_) { this.doFocusUpdate_ = false; this.scheduleFocusUpdate(); @@ -626,15 +634,22 @@ class NoteScreenComponent extends BaseScreenComponent imp }); } - if (this.props['plugins.shownEditorViewIds'] !== prevProps['plugins.shownEditorViewIds']) { - const { editorPlugin } = getShownPluginEditorView(this.props.plugins, this.props['plugins.shownEditorViewIds']); + if (this.props.visibleEditorPluginIds !== prevProps.visibleEditorPluginIds) { + const { editorPlugin } = getShownPluginEditorView(this.props.plugins, this.props.windowId); if (!editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime) { void shared.reloadNote(this); } } if (prevProps.noteId && this.props.noteId && prevProps.noteId !== this.props.noteId) { - void this.editorPluginHandler_.emitActivationCheck(); + void this.editorPluginHandler_.emitActivationCheck({ + noteId: this.props.noteId, + parentWindowId: defaultWindowId, + }); + } + + if (prevState.note.body !== this.state.note.body) { + this.emitEditorPluginUpdate_(); } } @@ -659,6 +674,13 @@ class NoteScreenComponent extends BaseScreenComponent imp this.setState({ newAndNoTitleChangeNoteId: null }); } + private emitEditorPluginUpdate_() { + this.editorPluginHandler_.emitUpdate({ + noteId: this.props.noteId, + newBody: this.state.note.body, + }, this.props.visibleEditorPluginIds); + } + private onPlainEditorTextChange = (text: string) => { if (!this.undoRedoService_.canUndo) { this.undoRedoService_.push(this.undoState()); @@ -1463,7 +1485,7 @@ class NoteScreenComponent extends BaseScreenComponent imp // multiple times. this.registerCommands(); - const { editorPlugin, editorView } = getShownPluginEditorView(this.props.plugins, this.props['plugins.shownEditorViewIds']); + const { editorPlugin, editorView } = getShownPluginEditorView(this.props.plugins, this.props.windowId); if (this.state.isLoading) { return ( @@ -1494,6 +1516,7 @@ class NoteScreenComponent extends BaseScreenComponent imp } const renderPluginEditor = () => { + this.editorPluginHandler_.onEditorPluginShown(editorView.id); return imp const voiceTypingDialogShown = this.state.showSpeechToTextDialog || this.state.showAudioRecorder; const renderActionButton = () => { if (voiceTypingDialogShown) return null; + if (editorView) return null; if (!this.state.note || !!this.state.note.deleted_time) return null; const editButton = { @@ -1670,7 +1694,7 @@ class NoteScreenComponent extends BaseScreenComponent imp return result; }; - const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins); + const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins, this.props.windowId); return ( @@ -1709,13 +1733,16 @@ class NoteScreenComponent extends BaseScreenComponent imp // how the new note should be rendered const NoteScreenWrapper = (props: Props) => { const dialogs = useContext(DialogContext); + const visibleEditorPluginIds = useVisiblePluginEditorViewIds(props.plugins, props.windowId); + return ( - + ); }; const NoteScreen = connect((state: AppState) => { return { + windowId: state.windowId, noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null, noteHash: state.selectedNoteHash, itemType: state.selectedItemType, @@ -1732,7 +1759,6 @@ const NoteScreen = connect((state: AppState) => { provisionalNoteIds: state.provisionalNoteIds, highlightedWords: state.highlightedWords, plugins: state.pluginService.plugins, - 'plugins.shownEditorViewIds': state.settings['plugins.shownEditorViewIds'] || [], pluginHtmlContents: state.pluginService.pluginHtmlContents, editorNoteReloadTimeRequest: state.editorNoteReloadTimeRequest, diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinCommands.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinCommands.d.ts index 0b7043185c..17d1004b30 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinCommands.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinCommands.d.ts @@ -14,7 +14,7 @@ import Plugin from '../Plugin'; * now, are not well documented. You can find the list directly on GitHub * though at the following locations: * - * * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands) + * * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/WindowCommandsAndDialogs/commands) * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands) * * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts) * @@ -25,8 +25,13 @@ import Plugin from '../Plugin'; * commands can be found in these places: * * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/commands) + * * [Note screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/components/screens/Note/commands) * * [Editor commands](https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/components/NoteEditor/commandDeclarations.ts) * + * Additionally, certain global commands have the same implementation on both platforms: + * + * * [Shared global commands](https://github.com/laurent22/joplin/tree/dev/packages/lib/commands) + * * ## Executing editor commands * * There might be a situation where you want to invoke editor commands diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts index ab9663dc1a..13cdca23d3 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts @@ -42,9 +42,11 @@ export default class JoplinSettings { */ values(keys: string[] | string): Promise>; /** - * @deprecated Use joplin.settings.values() + * Gets a setting value (only applies to setting you registered from your plugin). * - * Gets a setting value (only applies to setting you registered from your plugin) + * Note: If you want to retrieve all your plugin settings, for example when the plugin starts, + * it is recommended to use the `values()` function instead - it will be much faster than + * calling `value()` multiple times. */ value(key: string): Promise; /** diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinViews.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinViews.d.ts index 8a77ed1fd0..364e82a193 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinViews.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinViews.d.ts @@ -9,8 +9,17 @@ import JoplinViewsEditors from './JoplinViewsEditor'; /** * This namespace provides access to view-related services. * - * All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item. - * In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods. + * ## Creating a view + * + * All view services provide a `create()` method which you would use to create the view object, + * whether it's a dialog, a toolbar button or a menu item. In some cases, the `create()` method will + * return a [[ViewHandle]], which you would use to act on the view, for example to set certain + * properties or call some methods. + * + * ## The `webviewApi` object + * + * Within a view, you can use the global object `webviewApi` for various utility functions, such as + * sending messages or displaying context menu. Refer to [[WebviewApi]] for the full documentation. */ export default class JoplinViews { private store; diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinViewsEditor.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinViewsEditor.d.ts index bd4dd1631e..512c596f1c 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinViewsEditor.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinViewsEditor.d.ts @@ -1,5 +1,18 @@ import Plugin from '../Plugin'; -import { ActivationCheckCallback, ViewHandle, UpdateCallback } from './types'; +import { ActivationCheckCallback, ViewHandle, UpdateCallback, EditorPluginCallbacks } from './types'; +interface SaveNoteOptions { + /** + * The ID of the note to save. This should match either: + * - The ID of the note currently being edited + * - The ID of a note that was very recently open in the editor. + * + * This property is present to ensure that the note editor doesn't write + * to the wrong note just after switching notes. + */ + noteId: string; + /** The note's new content. */ + body: string; +} /** * Allows creating alternative note editors. You can create a view to handle loading and saving the * note, and do your own rendering. @@ -41,10 +54,18 @@ export default class JoplinViewsEditors { private store; private plugin; private activationCheckHandlers_; + private unhandledActivationCheck_; constructor(plugin: Plugin, store: any); private controller; + /** + * Registers a new editor plugin. Joplin will call the provided callback to create new editor views + * associated with the plugin as necessary (e.g. when a new editor is created in a new window). + */ + register(viewId: string, callbacks: EditorPluginCallbacks): Promise; /** * Creates a new editor view + * + * @deprecated */ create(id: string): Promise; /** @@ -59,11 +80,18 @@ export default class JoplinViewsEditors { * See [[JoplinViewPanels]] */ onMessage(handle: ViewHandle, callback: Function): Promise; + /** + * Saves the content of the editor, without calling `onUpdate` for editors in the same window. + */ + saveNote(handle: ViewHandle, props: SaveNoteOptions): Promise; /** * Emitted when the editor can potentially be activated - this is for example when the current * note is changed, or when the application is opened. At that point you should check the * current note and decide whether your editor should be activated or not. If it should, return * `true`, otherwise return `false`. + * + * @deprecated - `onActivationCheck` should be provided when the editor is first created with + * `editor.register`. */ onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise; /** @@ -86,3 +114,4 @@ export default class JoplinViewsEditors { */ isVisible(handle: ViewHandle): Promise; } +export {}; diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinViewsPanels.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinViewsPanels.d.ts index ba96c8a1a3..881dbb0839 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinViewsPanels.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinViewsPanels.d.ts @@ -80,5 +80,9 @@ export default class JoplinViewsPanels { * Tells whether the panel is visible or not */ visible(handle: ViewHandle): Promise; + /** + * Assuming that the current panel is an editor plugin view, returns + * whether the editor plugin view supports editing the current note. + */ isActive(handle: ViewHandle): Promise; } diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinWorkspace.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinWorkspace.d.ts index e13a96f8f8..9799f6f058 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinWorkspace.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinWorkspace.d.ts @@ -80,6 +80,8 @@ export default class JoplinWorkspace { filterEditorContextMenu(handler: FilterHandler): void; /** * Gets the currently selected note. Will be `null` if no note is selected. + * + * On desktop, this returns the selected note in the focused window. */ selectedNote(): Promise; /** @@ -93,5 +95,12 @@ export default class JoplinWorkspace { * Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes. */ selectedNoteIds(): Promise; + /** + * Gets the last hash (note section ID) from cross-note link targeting specific section. + * New hash is available after `onNoteSelectionChange()` is triggered. + * Example of cross-note link where `hello-world` is a hash: [Other Note Title](:/9bc9a5cb83f04554bf3fd3e41b4bb415#hello-world). + * Method returns empty value when a note was navigated with method other than cross-note link containing valid hash. + */ + selectedNoteHash(): Promise; } export {}; diff --git a/packages/generator-joplin/generators/app/templates/api/types.ts b/packages/generator-joplin/generators/app/templates/api/types.ts index 3cd7b0f0ae..b9fab8ff7d 100644 --- a/packages/generator-joplin/generators/app/templates/api/types.ts +++ b/packages/generator-joplin/generators/app/templates/api/types.ts @@ -397,9 +397,40 @@ export interface Rectangle { height?: number; } -export type ActivationCheckCallback = ()=> Promise; +export interface EditorUpdateEvent { + newBody: string; + noteId: string; +} +export type UpdateCallback = (event: EditorUpdateEvent)=> Promise; -export type UpdateCallback = ()=> Promise; + +export interface ActivationCheckEvent { + handle: ViewHandle; + noteId: string; +} +export type ActivationCheckCallback = (event: ActivationCheckEvent)=> Promise; + +/** + * Required callbacks for creating an editor plugin. + */ +export interface EditorPluginCallbacks { + /** + * Emitted when the editor can potentially be activated - this is for example when the current + * note is changed, or when the application is opened. At that point you should check the + * current note and decide whether your editor should be activated or not. If it should, return + * `true`, otherwise return `false`. + */ + onActivationCheck: ActivationCheckCallback; + + /** + * Emitted when an editor view is created. This happens, for example, when a new window containing + * a new editor is created. + * + * This callback should set the editor plugin's HTML using `editors.setHtml`, add scripts to the editor + * with `editors.addScript`, and optionally listen for external changes using `editors.onUpdate`. + */ + onSetup: (handle: ViewHandle)=> Promise; +} export type VisibleHandler = ()=> Promise; @@ -408,6 +439,8 @@ export interface EditContextMenuFilterObject { } export interface EditorActivationCheckFilterObject { + effectiveNoteId: string; + windowId: string; activatedEditors: { pluginId: string; viewId: string; @@ -417,6 +450,20 @@ export interface EditorActivationCheckFilterObject { export type FilterHandler = (object: T)=> Promise; +export type CommandArgument = string|number|object|boolean|null; + +export interface MenuTemplateItem { + label?: string; + command?: string; + commandArgs?: CommandArgument[]; +} + +export interface WebviewApi { + postMessage: (message: object)=> unknown; + onMessage: (message: object)=> void; + menuPopupFromTemplate: (template: MenuTemplateItem[])=> void; +} + // ================================================================= // Settings types // ================================================================= diff --git a/packages/lib/commands/showEditorPlugin.ts b/packages/lib/commands/showEditorPlugin.ts index 2d25655627..1bfd890def 100644 --- a/packages/lib/commands/showEditorPlugin.ts +++ b/packages/lib/commands/showEditorPlugin.ts @@ -1,7 +1,10 @@ import { CommandContext, CommandDeclaration, CommandRuntime } from '../services/CommandService'; -import Setting from '../models/Setting'; import getActivePluginEditorView from '../services/plugins/utils/getActivePluginEditorView'; import Logger from '@joplin/utils/Logger'; +import getActivePluginEditorViews from '../services/plugins/utils/getActivePluginEditorViews'; +import PluginService from '../services/plugins/PluginService'; +import WebviewController from '../services/plugins/WebviewController'; +import Setting from '../models/Setting'; const logger = Logger.create('showEditorPlugin'); @@ -16,10 +19,10 @@ export const runtime = (): CommandRuntime => { execute: async (context: CommandContext, editorViewId = '', show = true) => { logger.info('View:', editorViewId, 'Show:', show); - const shownEditorViewIds = Setting.value('plugins.shownEditorViewIds'); - + const pluginStates = context.state.pluginService.plugins; + const windowId = context.state.windowId; if (!editorViewId) { - const { editorPlugin, editorView } = getActivePluginEditorView(context.state.pluginService.plugins); + const { editorPlugin, editorView } = getActivePluginEditorView(pluginStates, windowId); if (!editorPlugin) { logger.warn('No editor plugin to toggle to'); @@ -29,27 +32,43 @@ export const runtime = (): CommandRuntime => { editorViewId = editorView.id; } - const idx = shownEditorViewIds.indexOf(editorViewId); - - if (show) { - if (idx >= 0) { - logger.info(`Editor is already visible: ${editorViewId}`); - return; - } - - shownEditorViewIds.push(editorViewId); - } else { - if (idx < 0) { - logger.info(`Editor is already hidden: ${editorViewId}`); - return; - } - - shownEditorViewIds.splice(idx, 1); + const activePlugins = getActivePluginEditorViews(pluginStates, windowId); + const editorPluginData = activePlugins.find(({ editorView }) => editorView.id === editorViewId); + if (!editorPluginData) { + logger.warn(`No editor view with ID ${editorViewId} is active.`); + return; + } + const { editorView } = editorPluginData; + const controller = PluginService.instance().viewControllerByViewId(editorView.id) as WebviewController; + if (!controller) { + throw new Error(`No controller registered for editor view ${editorView.id}`); } - logger.info('Shown editor IDs:', shownEditorViewIds); + const previousVisible = editorView.parentWindowId === windowId && controller.isVisible(); - Setting.setValue('plugins.shownEditorViewIds', shownEditorViewIds); + if (show && previousVisible) { + logger.info(`Editor is already visible: ${editorViewId}`); + return; + } else if (!show && !previousVisible) { + logger.info(`Editor is already hidden: ${editorViewId}`); + return; + } + + const getUpdatedShownViewIds = () => { + let newShownViewTypeIds = [...Setting.value('plugins.shownEditorViewIds')]; + // Always filter out the current view, even if show is false. This prevents + // the view ID from being present multiple times. + const viewIdsWithoutCurrent = newShownViewTypeIds.filter(id => id !== editorView.editorTypeId); + newShownViewTypeIds = viewIdsWithoutCurrent; + + if (show) { + newShownViewTypeIds.push(editorView.editorTypeId); + } + return newShownViewTypeIds; + }; + Setting.setValue('plugins.shownEditorViewIds', getUpdatedShownViewIds()); + + controller.setOpened(show); }, }; }; diff --git a/packages/lib/commands/toggleEditorPlugin.ts b/packages/lib/commands/toggleEditorPlugin.ts index 76317bc6a9..4ea36010af 100644 --- a/packages/lib/commands/toggleEditorPlugin.ts +++ b/packages/lib/commands/toggleEditorPlugin.ts @@ -1,8 +1,8 @@ -import { CommandContext, CommandDeclaration, CommandRuntime } from '../services/CommandService'; +import CommandService, { CommandContext, CommandDeclaration, CommandRuntime } from '../services/CommandService'; import { _ } from '../locale'; -import Setting from '../models/Setting'; -import getActivePluginEditorView from '../services/plugins/utils/getActivePluginEditorView'; import Logger from '@joplin/utils/Logger'; +import getActivePluginEditorViews from '../services/plugins/utils/getActivePluginEditorViews'; +import getShownPluginEditorView from '../services/plugins/utils/getShownPluginEditorView'; const logger = Logger.create('toggleEditorPlugin'); @@ -15,29 +15,34 @@ export const declaration: CommandDeclaration = { export const runtime = (): CommandRuntime => { return { execute: async (context: CommandContext) => { - const shownEditorViewIds = Setting.value('plugins.shownEditorViewIds'); - const { editorPlugin, editorView } = getActivePluginEditorView(context.state.pluginService.plugins); + const activeWindowId = context.state.windowId; + const activePluginStates = getActivePluginEditorViews(context.state.pluginService.plugins, activeWindowId); - if (!editorPlugin) { + if (activePluginStates.length === 0) { logger.warn('No editor plugin to toggle to'); return; } - const idx = shownEditorViewIds.indexOf(editorView.id); - let hasBeenHidden = false; + let showedView = false; + const setEditorPluginVisible = async (viewId: string, visible: boolean) => { + await CommandService.instance().execute('showEditorPlugin', viewId, visible); + showedView ||= visible; + }; - if (idx < 0) { - shownEditorViewIds.push(editorView.id); - } else { - shownEditorViewIds.splice(idx, 1); - hasBeenHidden = true; + const { editorView: visibleView } = getShownPluginEditorView(context.state.pluginService.plugins, activeWindowId); + // Hide the visible view + if (visibleView) { + await setEditorPluginVisible(visibleView.id, false); } - logger.info('New shown editor views: ', shownEditorViewIds); + // Show the next view + const visibleViewIndex = activePluginStates.findIndex(state => state.editorView.id === visibleView?.id); + const nextIndex = visibleViewIndex + 1; + if (nextIndex < activePluginStates.length) { + await setEditorPluginVisible(activePluginStates[nextIndex].editorView.id, true); + } - Setting.setValue('plugins.shownEditorViewIds', shownEditorViewIds); - - if (hasBeenHidden) { + if (!showedView) { // When the plugin editor goes from visible to hidden, we need to reload the note // because it may have been changed via the data API. context.dispatch({ diff --git a/packages/lib/components/shared/reduxSharedMiddleware.ts b/packages/lib/components/shared/reduxSharedMiddleware.ts index 4689d3a043..39d92b123c 100644 --- a/packages/lib/components/shared/reduxSharedMiddleware.ts +++ b/packages/lib/components/shared/reduxSharedMiddleware.ts @@ -5,7 +5,7 @@ import Note from '../../models/Note'; import { reg } from '../../registry'; import ResourceFetcher from '../../services/ResourceFetcher'; import DecryptionWorker from '../../services/DecryptionWorker'; -import eventManager from '../../eventManager'; +import eventManager, { EventName } from '../../eventManager'; import BaseItem from '../../models/BaseItem'; import shim from '../../shim'; import { Dispatch } from 'redux'; @@ -142,4 +142,14 @@ export default async (store: any, _next: any, action: any, dispatch: Dispatch) = } } } + + if (action.type === 'WINDOW_OPEN') { + eventManager.emit(EventName.WindowOpen, { + windowId: action.windowId, + }); + } else if (action.type === 'WINDOW_CLOSE') { + eventManager.emit(EventName.WindowClose, { + windowId: action.windowId, + }); + } }; diff --git a/packages/lib/eventManager.ts b/packages/lib/eventManager.ts index 21fd67bba2..bd7d7b784a 100644 --- a/packages/lib/eventManager.ts +++ b/packages/lib/eventManager.ts @@ -19,6 +19,8 @@ export enum EventName { NoteContentChange = 'noteContentChange', OcrServiceResourcesProcessed = 'ocrServiceResourcesProcessed', NoteResourceIndexed = 'noteResourceIndexed', + WindowOpen = 'windowOpen', + WindowClose = 'windowClose', } interface ItemChangeEvent { @@ -56,6 +58,14 @@ interface AlarmChangeEvent { note: NoteEntity; } +export interface WindowOpenEvent { + windowId: string; +} + +export interface WindowCloseEvent { + windowId: string; +} + type EventArgs = { [EventName.ResourceCreate]: []; [EventName.ResourceChange]: [ResourceChangeEvent]; @@ -71,6 +81,8 @@ type EventArgs = { [EventName.NoteContentChange]: [NoteContentChangeEvent]; [EventName.OcrServiceResourcesProcessed]: []; [EventName.NoteResourceIndexed]: []; + [EventName.WindowOpen]: [WindowOpenEvent]; + [EventName.WindowClose]: [WindowCloseEvent]; }; type EventListenerCallbacks = { diff --git a/packages/lib/hooks/usePlugin.ts b/packages/lib/hooks/plugins/usePlugin.ts similarity index 92% rename from packages/lib/hooks/usePlugin.ts rename to packages/lib/hooks/plugins/usePlugin.ts index 233f8b7921..586bcefd2e 100644 --- a/packages/lib/hooks/usePlugin.ts +++ b/packages/lib/hooks/plugins/usePlugin.ts @@ -1,6 +1,6 @@ -import PluginService from '../services/plugins/PluginService'; +import PluginService from '../../services/plugins/PluginService'; import Logger from '@joplin/utils/Logger'; -import shim from '../shim'; +import shim from '../../shim'; const logger = Logger.create('usePlugin'); diff --git a/packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.ts b/packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.ts new file mode 100644 index 0000000000..4bb7bee750 --- /dev/null +++ b/packages/lib/hooks/plugins/useVisiblePluginEditorViewIds.ts @@ -0,0 +1,13 @@ +import { PluginStates } from '../../services/plugins/reducer'; +import getActivePluginEditorViews from '../../services/plugins/utils/getActivePluginEditorViews'; +import shim from '../../shim'; +const { useMemo } = shim.react(); + +const useVisiblePluginEditorViewIds = (plugins: PluginStates, windowId: string) => { + return useMemo(() => { + const visibleViews = getActivePluginEditorViews(plugins, windowId, { mustBeVisible: true }); + return visibleViews.flatMap(({ editorView }) => editorView.id); + }, [plugins, windowId]); +}; + +export default useVisiblePluginEditorViewIds; diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts index fa885ff77f..732383d1c3 100644 --- a/packages/lib/services/commands/stateToWhenClauseContext.ts +++ b/packages/lib/services/commands/stateToWhenClauseContext.ts @@ -63,7 +63,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau const commandFolderId = state.notesParentType === 'Folder' ? (options.commandFolderId || windowState.selectedFolderId) : ''; const commandFolder: FolderEntity = commandFolderId ? BaseModel.byId(state.folders, commandFolderId) : null; - const { editorPlugin } = state.pluginService ? getActivePluginEditorView(state.pluginService.plugins) : { editorPlugin: null }; + const { editorPlugin } = state.pluginService ? getActivePluginEditorView(state.pluginService.plugins, windowState.windowId) : { editorPlugin: null }; const settings = state.settings || {}; diff --git a/packages/lib/services/plugins/EditorPluginHandler.ts b/packages/lib/services/plugins/EditorPluginHandler.ts index dfab687936..bd3465553c 100644 --- a/packages/lib/services/plugins/EditorPluginHandler.ts +++ b/packages/lib/services/plugins/EditorPluginHandler.ts @@ -12,32 +12,81 @@ import WebviewController from './WebviewController'; const logger = Logger.create('EditorPluginHandler'); -const makeNoteUpdateAction = (pluginService: PluginService, shownEditorViewIds: string[]) => { +export interface UpdateEvent { + noteId: string; + newBody: string; +} + +interface EmitActivationCheckOptions { + noteId: string; + parentWindowId: string; +} + +interface SaveNoteEvent { + id: string; + body: string; +} + +export type OnSaveNoteCallback = (updatedNote: SaveNoteEvent)=> void; + +const makeNoteUpdateAction = (pluginService: PluginService, event: UpdateEvent, shownEditorViewIds: string[]) => { return async () => { for (const viewId of shownEditorViewIds) { const controller = pluginService.viewControllerByViewId(viewId) as WebviewController; - if (controller) controller.emitUpdate(); + if (controller) { + controller.emitUpdate({ + noteId: event.noteId, + newBody: event.newBody, + }); + } } }; }; export default class { - private pluginService_: PluginService; private viewUpdateAsyncQueue_ = new AsyncActionQueue(100, IntervalType.Fixed); + private lastNoteState_: UpdateEvent|null = null; + private lastShownEditorViewIds_ = ''; + private lastEditorPluginShown_: string|null = null; - public constructor(pluginService: PluginService) { - this.pluginService_ = pluginService; + public constructor( + private pluginService_: PluginService, + private onSaveNote_: OnSaveNoteCallback, + ) { } - public emitUpdate(shownEditorViewIds: string[]) { - logger.info('emitUpdate:', shownEditorViewIds); - this.viewUpdateAsyncQueue_.push(makeNoteUpdateAction(this.pluginService_, shownEditorViewIds)); + public emitUpdate(event: UpdateEvent, shownEditorViewIds: string[]) { + if (shownEditorViewIds.length === 0) return; + + const isEventDifferentFrom = (other: UpdateEvent|null) => { + if (!other) return true; + return event.noteId !== other.noteId || event.newBody !== other.newBody; + }; + + const shownEditorViewIdsString = shownEditorViewIds.join(','); + const differentEditorViewsShown = shownEditorViewIdsString !== this.lastShownEditorViewIds_; + + // lastNoteState_ often contains the last change saved by the editor. As a result, + // if `event` matches `lastNoteState_`, the event was probably caused by the last save. + // In this case, avoid sending an update event (which plugins often interpret as refreshing + // the editor): + const isDifferentFromSave = isEventDifferentFrom(this.lastNoteState_); + + if (isDifferentFromSave || differentEditorViewsShown) { + logger.info('emitUpdate:', shownEditorViewIds); + this.viewUpdateAsyncQueue_.push(makeNoteUpdateAction(this.pluginService_, event, shownEditorViewIds)); + + this.lastNoteState_ = { ...event }; + this.lastShownEditorViewIds_ = shownEditorViewIdsString; + } } - public async emitActivationCheck() { + public async emitActivationCheck({ noteId, parentWindowId }: EmitActivationCheckOptions) { let filterObject: EditorActivationCheckFilterObject = { activatedEditors: [], + effectiveNoteId: noteId, + windowId: parentWindowId, }; filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject); @@ -45,8 +94,34 @@ export default class { for (const editor of filterObject.activatedEditors) { const controller = this.pluginService_.pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController; - controller.setActive(editor.isActive); + if (controller.parentWindowId === parentWindowId) { + controller.setActive(editor.isActive); + } } } + public onEditorPluginShown(editorViewId: string) { + // Don't double-register callbacks + if (editorViewId === this.lastEditorPluginShown_) { + return; + } + this.lastEditorPluginShown_ = editorViewId; + + const controller = this.pluginService_.viewControllerByViewId(editorViewId) as WebviewController; + controller?.onNoteSaveRequested(event => { + this.scheduleSaveNote_(event.noteId, event.body); + }); + } + + private scheduleSaveNote_(noteId: string, noteBody: string) { + this.lastNoteState_ = { + noteId, + newBody: noteBody, + }; + + return this.onSaveNote_({ + id: noteId, + body: noteBody, + }); + } } diff --git a/packages/lib/services/plugins/Plugin.ts b/packages/lib/services/plugins/Plugin.ts index 1d6c09bec8..36b25ab90f 100644 --- a/packages/lib/services/plugins/Plugin.ts +++ b/packages/lib/services/plugins/Plugin.ts @@ -180,6 +180,10 @@ export default class Plugin { this.viewControllers_[v.handle] = v; } + public removeViewController(v: ViewController) { + delete this.viewControllers_[v.handle]; + } + public hasViewController(handle: ViewHandle) { return !!this.viewControllers_[handle]; } @@ -229,6 +233,10 @@ export default class Plugin { this.onUnloadListeners_.push(callback); } + public removeOnUnloadListener(callback: OnUnloadListener) { + this.onUnloadListeners_ = this.onUnloadListeners_.filter(other => other !== callback); + } + public onUnload() { for (const callback of this.onUnloadListeners_) { callback(); diff --git a/packages/lib/services/plugins/WebviewController.ts b/packages/lib/services/plugins/WebviewController.ts index 4ee03b39b1..a41bbd00be 100644 --- a/packages/lib/services/plugins/WebviewController.ts +++ b/packages/lib/services/plugins/WebviewController.ts @@ -3,10 +3,9 @@ import shim from '../../shim'; import { ButtonSpec, DialogResult, ViewHandle } from './api/types'; const { toSystemSlashes } = require('../../path-utils'); import PostMessageService, { MessageParticipant } from '../PostMessageService'; -import { PluginViewState } from './reducer'; +import { PluginEditorViewState, PluginViewState } from './reducer'; import { defaultWindowId } from '../../reducer'; import Logger from '@joplin/utils/Logger'; -import CommandService from '../CommandService'; const logger = Logger.create('WebviewController'); @@ -49,32 +48,48 @@ function findItemByKey(layout: any, key: string): any { return recurseFind(layout); } +interface EditorUpdateEvent { + noteId: string; + newBody: string; +} +type EditorUpdateListener = (event: EditorUpdateEvent)=> void; + +interface SaveNoteEvent { + noteId: string; + body: string; +} +type OnSaveNoteCallback = (saveNoteEvent: SaveNoteEvent)=> void; + export default class WebviewController extends ViewController { private baseDir_: string; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied private messageListener_: Function = null; - private updateListener_: ()=> void = null; + private updateListener_: EditorUpdateListener|null = null; private closeResponse_: CloseResponse = null; private containerType_: ContainerType = null; + private saveNoteListener_: OnSaveNoteCallback|null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public constructor(handle: ViewHandle, pluginId: string, store: any, baseDir: string, containerType: ContainerType) { + public constructor(handle: ViewHandle, pluginId: string, store: any, baseDir: string, containerType: ContainerType, parentWindowId: string|null) { super(handle, pluginId, store); this.baseDir_ = toSystemSlashes(baseDir, 'linux'); this.containerType_ = containerType; const view: PluginViewState = { id: this.handle, + editorTypeId: '', type: this.type, containerType: containerType, html: '', scripts: [], + buttons: null, + fitToContent: true, // Opened is used for dialogs and mobile panels (which are shown // like dialogs): opened: containerType === ContainerType.Panel, - buttons: null, - fitToContent: true, + active: false, + parentWindowId, }; this.store.dispatch({ @@ -84,10 +99,23 @@ export default class WebviewController extends ViewController { }); } + public destroy() { + this.store.dispatch({ + type: 'PLUGIN_VIEW_REMOVE', + pluginId: this.pluginId, + viewId: this.storeView.id, + }); + } + public get type(): string { return 'webview'; } + // Returns `null` if the view can be shown in any window. + public get parentWindowId(): string { + return this.storeView.parentWindowId; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private setStoreProp(name: string, value: any) { this.store.dispatch({ @@ -127,7 +155,6 @@ export default class WebviewController extends ViewController { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public postMessage(message: any) { - const messageId = `plugin_${Date.now()}${Math.random()}`; void PostMessageService.instance().postMessage({ @@ -146,15 +173,10 @@ export default class WebviewController extends ViewController { public async emitMessage(event: EmitMessageEvent) { if (!this.messageListener_) return; - if (this.containerType_ === ContainerType.Editor && !this.isActive()) { - logger.info('emitMessage: Not emitting message because editor is disabled:', this.pluginId, this.handle); - return; - } - return this.messageListener_(event.message); } - public emitUpdate() { + public emitUpdate(event: EditorUpdateEvent) { if (!this.updateListener_) return; if (this.containerType_ === ContainerType.Editor && (!this.isActive() || !this.isVisible())) { @@ -162,7 +184,7 @@ export default class WebviewController extends ViewController { return; } - this.updateListener_(); + this.updateListener_(event); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -170,8 +192,7 @@ export default class WebviewController extends ViewController { this.messageListener_ = callback; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public onUpdate(callback: any) { + public onUpdate(callback: EditorUpdateListener) { this.updateListener_ = callback; } @@ -271,22 +292,37 @@ export default class WebviewController extends ViewController { // Specific to editors // --------------------------------------------- + public setEditorTypeId(id: string) { + this.setStoreProp('editorTypeId', id); + } + public setActive(active: boolean) { - this.setStoreProp('opened', active); + this.setStoreProp('active', active); } public isActive(): boolean { - return this.storeView.opened; + const state = this.storeView as PluginEditorViewState; + return state.active; + } + + public setOpened(visible: boolean) { + this.setStoreProp('opened', visible); } public isVisible(): boolean { - if (!this.storeView.opened) return false; - const shownEditorViewIds: string[] = this.store.getState().settings['plugins.shownEditorViewIds']; - return shownEditorViewIds.includes(this.handle); + const state = this.storeView as PluginEditorViewState; + return state.active && state.opened; } - public async setVisible(visible: boolean) { - await CommandService.instance().execute('showEditorPlugin', this.handle, visible); + public async requestSaveNote(event: SaveNoteEvent) { + if (!this.saveNoteListener_) { + logger.warn('Note save requested, but no save handler was registered. View ID: ', this.storeView?.id); + return; + } + this.saveNoteListener_(event); } + public onNoteSaveRequested(listener: OnSaveNoteCallback) { + this.saveNoteListener_ = listener; + } } diff --git a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts index 62495da41c..429e3b790a 100644 --- a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts +++ b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts @@ -64,7 +64,7 @@ export default class JoplinViewsDialogs { } const handle = createViewHandle(this.plugin, id); - const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Dialog); + const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Dialog, null); this.plugin.addViewController(controller); return handle; } diff --git a/packages/lib/services/plugins/api/JoplinViewsEditor.ts b/packages/lib/services/plugins/api/JoplinViewsEditor.ts index 8be7b5a001..2f6bce1618 100644 --- a/packages/lib/services/plugins/api/JoplinViewsEditor.ts +++ b/packages/lib/services/plugins/api/JoplinViewsEditor.ts @@ -1,10 +1,28 @@ /* eslint-disable multiline-comment-style */ -import eventManager from '../../../eventManager'; +import eventManager, { EventName, WindowCloseEvent, WindowOpenEvent } from '../../../eventManager'; +import Setting from '../../../models/Setting'; +import { defaultWindowId } from '../../../reducer'; import Plugin from '../Plugin'; import createViewHandle from '../utils/createViewHandle'; import WebviewController, { ContainerType } from '../WebviewController'; -import { ActivationCheckCallback, EditorActivationCheckFilterObject, FilterHandler, ViewHandle, UpdateCallback } from './types'; +import { ActivationCheckCallback, EditorActivationCheckFilterObject, FilterHandler, ViewHandle, UpdateCallback, EditorPluginCallbacks } from './types'; + +interface SaveNoteOptions { + /** + * The ID of the note to save. This should match either: + * - The ID of the note currently being edited + * - The ID of a note that was very recently open in the editor. + * + * This property is present to ensure that the note editor doesn't write + * to the wrong note just after switching notes. + */ + noteId: string; + /** The note's new content. */ + body: string; +} + +type ActivationCheckSlice = Pick; /** * Allows creating alternative note editors. You can create a view to handle loading and saving the @@ -49,6 +67,7 @@ export default class JoplinViewsEditors { private store: any; private plugin: Plugin; private activationCheckHandlers_: Record> = {}; + private unhandledActivationCheck_: Map = new Map(); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public constructor(plugin: Plugin, store: any) { @@ -60,14 +79,106 @@ export default class JoplinViewsEditors { return this.plugin.viewController(handle) as WebviewController; } + /** + * Registers a new editor plugin. Joplin will call the provided callback to create new editor views + * associated with the plugin as necessary (e.g. when a new editor is created in a new window). + */ + public async register(viewId: string, callbacks: EditorPluginCallbacks) { + const initializeController = (handle: ViewHandle, windowId: string) => { + const editorTypeId = `${this.plugin.id}-${viewId}`; + const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Editor, windowId); + controller.setEditorTypeId(editorTypeId); + this.plugin.addViewController(controller); + // Restore the last open/closed state for the editor + controller.setOpened(Setting.value('plugins.shownEditorViewIds').includes(editorTypeId)); + + return () => { + this.plugin.removeViewController(controller); + controller.destroy(); + }; + }; + + // Register the activation check handler early to handle the case where the editorActivationCheck + // event is fired **before** an activation check handler is registered through the API. + const registerActivationCheckHandler = (handle: ViewHandle) => { + const onActivationCheck: FilterHandler = async object => { + if (this.activationCheckHandlers_[handle]) { + return this.activationCheckHandlers_[handle](object); + } else { + this.unhandledActivationCheck_.set(handle, { + ...object, + }); + return object; + } + }; + eventManager.filterOn('editorActivationCheck', onActivationCheck); + const cleanup = () => { + eventManager.filterOff('editorActivationCheck', onActivationCheck); + this.unhandledActivationCheck_.delete(handle); + }; + + return cleanup; + }; + + const listenForWindowOrPluginClose = (windowId: string, onClose: ()=> void) => { + const closeListener = (event: WindowCloseEvent|null) => { + if (event && event.windowId !== windowId) return; + + onClose(); + eventManager.off(EventName.WindowClose, closeListener); + }; + eventManager.on(EventName.WindowClose, closeListener); + + this.plugin.addOnUnloadListener(() => { + closeListener(null); + }); + }; + + const createEditorViewForWindow = async (windowId: string) => { + const handle = createViewHandle(this.plugin, `${viewId}-${windowId}`); + + const removeController = initializeController(handle, windowId); + const removeActivationCheck = registerActivationCheckHandler(handle); + + await callbacks.onSetup(handle); + + // Register the activation check after calling onSetup to ensure that the editor + // is fully set up before it can be marked as active. + await this.onActivationCheck(handle, callbacks.onActivationCheck); + + listenForWindowOrPluginClose(windowId, () => { + // Save resources by closing resources associated with + // closed windows: + removeController(); + removeActivationCheck(); + }); + }; + + await createEditorViewForWindow(defaultWindowId); + + const onWindowOpen = (event: WindowOpenEvent) => createEditorViewForWindow(event.windowId); + eventManager.on(EventName.WindowOpen, onWindowOpen); + this.plugin.addOnUnloadListener(() => { + eventManager.off(EventName.WindowOpen, onWindowOpen); + }); + } + /** * Creates a new editor view + * + * @deprecated */ public async create(id: string): Promise { - const handle = createViewHandle(this.plugin, id); - const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Editor); - this.plugin.addViewController(controller); - return handle; + return new Promise(resolve => { + void this.register(id, { + onSetup: async (handle) => { + resolve(handle); + }, + onActivationCheck: async () => { + return false; + }, + }); + }); } /** @@ -92,29 +203,52 @@ export default class JoplinViewsEditors { return this.controller(handle).onMessage(callback); } + /** + * Saves the content of the editor, without calling `onUpdate` for editors in the same window. + */ + public async saveNote(handle: ViewHandle, props: SaveNoteOptions): Promise { + await this.controller(handle).requestSaveNote({ + noteId: props.noteId, + body: props.body, + }); + } + /** * Emitted when the editor can potentially be activated - this is for example when the current * note is changed, or when the application is opened. At that point you should check the * current note and decide whether your editor should be activated or not. If it should, return * `true`, otherwise return `false`. + * + * @deprecated - `onActivationCheck` should be provided when the editor is first created with + * `editor.register`. */ public async onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise { - const handler: FilterHandler = async (object) => { - const isActive = await callback(); + const isActive = async ({ windowId, effectiveNoteId }: ActivationCheckSlice) => { + const isCorrectWindow = windowId === this.controller(handle).parentWindowId; + const active = isCorrectWindow && await callback({ + handle, + noteId: effectiveNoteId, + }); + return active; + }; + const handler = async (object: ActivationCheckSlice) => { object.activatedEditors.push({ pluginId: this.plugin.id, viewId: handle, - isActive: isActive, + isActive: await isActive(object), }); return object; }; this.activationCheckHandlers_[handle] = handler; - eventManager.filterOn('editorActivationCheck', this.activationCheckHandlers_[handle]); - this.plugin.addOnUnloadListener(() => { - eventManager.filterOff('editorActivationCheck', this.activationCheckHandlers_[handle]); - }); + // Handle the case where the activation check was done before this onActivationCheck handler was registered. + if (this.unhandledActivationCheck_.has(handle)) { + const lastActivationCheckObject = this.unhandledActivationCheck_.get(handle); + this.unhandledActivationCheck_.delete(handle); + + this.controller(handle).setActive(await isActive(lastActivationCheckObject)); + } } /** @@ -137,7 +271,7 @@ export default class JoplinViewsEditors { * Tells whether the editor is active or not. */ public async isActive(handle: ViewHandle): Promise { - return this.controller(handle).visible; + return this.controller(handle).isActive(); } /** diff --git a/packages/lib/services/plugins/api/JoplinViewsPanels.ts b/packages/lib/services/plugins/api/JoplinViewsPanels.ts index 8a8bcf8444..12e7a8365e 100644 --- a/packages/lib/services/plugins/api/JoplinViewsPanels.ts +++ b/packages/lib/services/plugins/api/JoplinViewsPanels.ts @@ -1,5 +1,6 @@ /* eslint-disable multiline-comment-style */ +import { defaultWindowId } from '../../../reducer'; import Plugin from '../Plugin'; import createViewHandle from '../utils/createViewHandle'; import WebviewController, { ContainerType } from '../WebviewController'; @@ -45,7 +46,7 @@ export default class JoplinViewsPanels { } const handle = createViewHandle(this.plugin, id); - const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Panel); + const controller = new WebviewController(handle, this.plugin.id, this.store, this.plugin.baseDir, ContainerType.Panel, defaultWindowId); this.plugin.addViewController(controller); return handle; } @@ -130,6 +131,10 @@ export default class JoplinViewsPanels { return this.controller(handle).visible; } + /** + * Assuming that the current panel is an editor plugin view, returns + * whether the editor plugin view supports editing the current note. + */ public async isActive(handle: ViewHandle): Promise { return this.controller(handle).isActive(); } diff --git a/packages/lib/services/plugins/api/JoplinWorkspace.ts b/packages/lib/services/plugins/api/JoplinWorkspace.ts index 91c1565b84..f5fe47af8a 100644 --- a/packages/lib/services/plugins/api/JoplinWorkspace.ts +++ b/packages/lib/services/plugins/api/JoplinWorkspace.ts @@ -171,6 +171,8 @@ export default class JoplinWorkspace { /** * Gets the currently selected note. Will be `null` if no note is selected. + * + * On desktop, this returns the selected note in the focused window. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public async selectedNote(): Promise { diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index 3138f39cf3..b9fab8ff7d 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -397,9 +397,40 @@ export interface Rectangle { height?: number; } -export type ActivationCheckCallback = ()=> Promise; +export interface EditorUpdateEvent { + newBody: string; + noteId: string; +} +export type UpdateCallback = (event: EditorUpdateEvent)=> Promise; -export type UpdateCallback = ()=> Promise; + +export interface ActivationCheckEvent { + handle: ViewHandle; + noteId: string; +} +export type ActivationCheckCallback = (event: ActivationCheckEvent)=> Promise; + +/** + * Required callbacks for creating an editor plugin. + */ +export interface EditorPluginCallbacks { + /** + * Emitted when the editor can potentially be activated - this is for example when the current + * note is changed, or when the application is opened. At that point you should check the + * current note and decide whether your editor should be activated or not. If it should, return + * `true`, otherwise return `false`. + */ + onActivationCheck: ActivationCheckCallback; + + /** + * Emitted when an editor view is created. This happens, for example, when a new window containing + * a new editor is created. + * + * This callback should set the editor plugin's HTML using `editors.setHtml`, add scripts to the editor + * with `editors.addScript`, and optionally listen for external changes using `editors.onUpdate`. + */ + onSetup: (handle: ViewHandle)=> Promise; +} export type VisibleHandler = ()=> Promise; @@ -408,6 +439,8 @@ export interface EditContextMenuFilterObject { } export interface EditorActivationCheckFilterObject { + effectiveNoteId: string; + windowId: string; activatedEditors: { pluginId: string; viewId: string; diff --git a/packages/lib/services/plugins/reducer.ts b/packages/lib/services/plugins/reducer.ts index 926e3fc0c5..92f2a63741 100644 --- a/packages/lib/services/plugins/reducer.ts +++ b/packages/lib/services/plugins/reducer.ts @@ -2,23 +2,39 @@ import { Draft } from 'immer'; import { ContainerType } from './WebviewController'; import { ButtonSpec } from './api/types'; -export interface PluginViewState { +interface PluginViewStateBase { id: string; type: string; - // Note that this property will mean different thing depending on the `containerType`. If it's a - // dialog, it means that the dialog is opened. If it's a panel, it means it's visible/opened. If - // it's an editor, it means the editor is currently active (but it may not be visible - see - // JoplinViewsEditor). - opened: boolean; buttons: ButtonSpec[]; fitToContent?: boolean; scripts?: string[]; html?: string; commandName?: string; location?: string; - containerType: ContainerType; + opened: boolean; } +export interface PluginEditorViewState extends PluginViewStateBase { + containerType: ContainerType.Editor; + + parentWindowId: string; + active: boolean; + + // A non-unique ID determined by the type of the editor. Unlike the id property, + // this is the same for editor views of the same type opened in different windows. + editorTypeId: string; +} + +interface PluginDialogViewState extends PluginViewStateBase { + containerType: ContainerType.Dialog; +} + +interface PluginPanelViewState extends PluginViewStateBase { + containerType: ContainerType.Panel; +} + +export type PluginViewState = PluginEditorViewState|PluginDialogViewState|PluginPanelViewState; + interface PluginViewStates { [key: string]: PluginViewState; } @@ -166,6 +182,10 @@ const reducer = (draftRoot: Draft, action: any) => { draft.plugins[action.pluginId].views[action.view.id] = { ...action.view }; break; + case 'PLUGIN_VIEW_REMOVE': + delete draft.plugins[action.pluginId].views[action.viewId]; + break; + case 'PLUGIN_VIEW_PROP_SET': if (action.name !== 'html') { diff --git a/packages/lib/services/plugins/utils/getActivePluginEditorView.ts b/packages/lib/services/plugins/utils/getActivePluginEditorView.ts index 72dd537e4b..fc712d3578 100644 --- a/packages/lib/services/plugins/utils/getActivePluginEditorView.ts +++ b/packages/lib/services/plugins/utils/getActivePluginEditorView.ts @@ -1,28 +1,22 @@ import Logger from '@joplin/utils/Logger'; -import { PluginState, PluginStates, PluginViewState } from '../reducer'; -import { ContainerType } from '../WebviewController'; +import { PluginStates } from '../reducer'; +import getActivePluginEditorViews from './getActivePluginEditorViews'; const logger = Logger.create('getActivePluginEditorView'); -interface Output { - editorPlugin: PluginState; - editorView: PluginViewState; -} +export default (plugins: PluginStates, windowId: string) => { + const allActiveViews = getActivePluginEditorViews(plugins, windowId); -export default (plugins: PluginStates) => { - let output: Output = { editorPlugin: null, editorView: null }; - for (const [, pluginState] of Object.entries(plugins)) { - for (const [, view] of Object.entries(pluginState.views)) { - if (view.type === 'webview' && view.containerType === ContainerType.Editor && view.opened) { - if (output.editorPlugin) { - logger.warn(`More than one editor plugin are active for this note. Active plugin: ${output.editorPlugin.id}. Ignored plugin: ${pluginState.id}`); - } else { - output = { editorPlugin: pluginState, editorView: view }; - } - } - } + if (allActiveViews.length === 0) { + return { editorPlugin: null, editorView: null }; } - return output; + const result = allActiveViews[0]; + if (allActiveViews.length > 1) { + const ignoredPluginIds = allActiveViews.slice(1).map(({ editorPlugin }) => editorPlugin.id); + logger.warn(`More than one editor plugin are active for this note. Active plugin: ${result.editorPlugin.id}. Ignored plugins: ${ignoredPluginIds.join(',')}`); + } + + return allActiveViews[0]; }; diff --git a/packages/lib/services/plugins/utils/getActivePluginEditorViews.ts b/packages/lib/services/plugins/utils/getActivePluginEditorViews.ts new file mode 100644 index 0000000000..fbae20ae61 --- /dev/null +++ b/packages/lib/services/plugins/utils/getActivePluginEditorViews.ts @@ -0,0 +1,27 @@ +import { PluginStates } from '../reducer'; +import { ContainerType } from '../WebviewController'; + +interface Options { + mustBeVisible?: boolean; +} + +export default (plugins: PluginStates, windowId: string, { mustBeVisible = false }: Options = {}) => { + const output = []; + + for (const [, pluginState] of Object.entries(plugins)) { + for (const [, view] of Object.entries(pluginState.views)) { + if (view.type !== 'webview' || view.containerType !== ContainerType.Editor) continue; + if (view.parentWindowId !== windowId || !view.active) continue; + + output.push({ editorPlugin: pluginState, editorView: view }); + } + } + + if (mustBeVisible) { + // Filter out views that haven't been shown: + return output.filter(({ editorView }) => editorView.opened); + } + + return output; +}; + diff --git a/packages/lib/services/plugins/utils/getShownPluginEditorView.ts b/packages/lib/services/plugins/utils/getShownPluginEditorView.ts index 072e045894..60e4e234d9 100644 --- a/packages/lib/services/plugins/utils/getShownPluginEditorView.ts +++ b/packages/lib/services/plugins/utils/getShownPluginEditorView.ts @@ -1,10 +1,10 @@ import { PluginStates } from '../reducer'; -import getActivePluginEditorView from './getActivePluginEditorView'; +import getActivePluginEditorViews from './getActivePluginEditorViews'; -export default (plugins: PluginStates, shownEditorViewIds: string[]) => { - const { editorPlugin, editorView } = getActivePluginEditorView(plugins); - if (editorView) { - if (!shownEditorViewIds.includes(editorView.id)) return { editorPlugin: null, editorView: null }; +export default (plugins: PluginStates, windowId: string) => { + const visibleViews = getActivePluginEditorViews(plugins, windowId, { mustBeVisible: true }); + if (!visibleViews.length) { + return { editorView: null, editorPlugin: null }; } - return { editorPlugin, editorView }; + return visibleViews[0]; }; diff --git a/packages/lib/services/plugins/utils/getShownPluginEditorViewIds.ts b/packages/lib/services/plugins/utils/getShownPluginEditorViewIds.ts new file mode 100644 index 0000000000..bd0f9249ae --- /dev/null +++ b/packages/lib/services/plugins/utils/getShownPluginEditorViewIds.ts @@ -0,0 +1,8 @@ +import { PluginStates } from '../reducer'; +import getActivePluginEditorViews from './getActivePluginEditorViews'; + +export default (state: PluginStates, windowId: string) => { + return getActivePluginEditorViews( + state, windowId, { mustBeVisible: true }, + ).map(({ editorView }) => editorView.id); +}; diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json index 5ea6ebc46b..f66e47f676 100644 --- a/packages/lib/tsconfig.json +++ b/packages/lib/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "include": [ "**/*.ts", - "**/*.tsx", "../app-desktop/commands/emptyTrash.ts", + "**/*.tsx" ], "exclude": [ "**/node_modules",