diff --git a/.eslintignore b/.eslintignore index 4bc6090f32..2bbaeb1617 100644 --- a/.eslintignore +++ b/.eslintignore @@ -292,6 +292,8 @@ packages/app-desktop/gui/NoteEditor/utils/useFormNote.js packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js +packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js +packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.js packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js @@ -445,6 +447,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js @@ -453,6 +456,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js @@ -1226,6 +1230,7 @@ packages/lib/services/plugins/api/JoplinPlugins.js packages/lib/services/plugins/api/JoplinSettings.js packages/lib/services/plugins/api/JoplinViews.js packages/lib/services/plugins/api/JoplinViewsDialogs.js +packages/lib/services/plugins/api/JoplinViewsEditor.js packages/lib/services/plugins/api/JoplinViewsMenuItems.js packages/lib/services/plugins/api/JoplinViewsMenus.js packages/lib/services/plugins/api/JoplinViewsNoteList.js @@ -1244,6 +1249,7 @@ packages/lib/services/plugins/testing/MockPlatformImplementation.js 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/getPluginIssueReportUrl.test.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.js packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js diff --git a/.gitignore b/.gitignore index 1fe3d501a4..dc240afeed 100644 --- a/.gitignore +++ b/.gitignore @@ -269,6 +269,8 @@ packages/app-desktop/gui/NoteEditor/utils/useFormNote.js packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.js packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js +packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js +packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.js packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js @@ -422,6 +424,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js @@ -430,6 +433,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js +packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js @@ -1203,6 +1207,7 @@ packages/lib/services/plugins/api/JoplinPlugins.js packages/lib/services/plugins/api/JoplinSettings.js packages/lib/services/plugins/api/JoplinViews.js packages/lib/services/plugins/api/JoplinViewsDialogs.js +packages/lib/services/plugins/api/JoplinViewsEditor.js packages/lib/services/plugins/api/JoplinViewsMenuItems.js packages/lib/services/plugins/api/JoplinViewsMenus.js packages/lib/services/plugins/api/JoplinViewsNoteList.js @@ -1221,6 +1226,7 @@ packages/lib/services/plugins/testing/MockPlatformImplementation.js 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/getPluginIssueReportUrl.test.js packages/lib/services/plugins/utils/getPluginIssueReportUrl.js packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js diff --git a/packages/app-desktop/gui/MainScreen.tsx b/packages/app-desktop/gui/MainScreen.tsx index ed82617bb0..e22414b650 100644 --- a/packages/app-desktop/gui/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen.tsx @@ -637,6 +637,7 @@ class MainScreenComponent extends React.Component { ; }, diff --git a/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx b/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx index 4443b26fc0..b52c678ab6 100644 --- a/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx +++ b/packages/app-desktop/gui/NoteEditor/EditorWindow.tsx @@ -21,6 +21,7 @@ interface Props { newWindow: boolean; windowId: string; activeWindowId: string; + startupPluginsLoaded: boolean; } const emptyCallback = () => {}; @@ -45,6 +46,7 @@ const SecondaryWindow: React.FC = props => { ; @@ -121,5 +123,6 @@ export default connect((state: AppState, ownProps: ConnectProps) => { codeView: windowState?.editorCodeView ?? state.settings['editor.codeView'], legacyMarkdown: state.settings['editor.legacyMarkdown'], activeWindowId: stateUtils.activeWindowId(state), + startupPluginsLoaded: state.startupPluginsLoaded, }; })(SecondaryWindow); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts index 67f0378c42..6219b0caa0 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts @@ -3,11 +3,10 @@ import { ContextMenuParams, Event } from 'electron'; import { useEffect, RefObject } from 'react'; import { _ } from '@joplin/lib/locale'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; -import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; +import { EditContextMenuFilterObject, MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import CommandService from '@joplin/lib/services/CommandService'; import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; -import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace'; import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; import eventManager from '@joplin/lib/eventManager'; import bridge from '../../../../../services/bridge'; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 6c1906a5d7..7768ecfb2b 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -32,7 +32,6 @@ import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly'; const { themeStyle } = require('@joplin/lib/theme'); const { substrWithEllipsis } = require('@joplin/lib/string-utils'); import NoteSearchBar from '../NoteSearchBar'; -import { reg } from '@joplin/lib/registry'; import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import NoteRevisionViewer from '../NoteRevisionViewer'; @@ -51,10 +50,20 @@ import { MarkupLanguage } from '@joplin/renderer'; import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions'; import useScheduleSaveCallbacks from './utils/useScheduleSaveCallbacks'; import WarningBanner from './WarningBanner/WarningBanner'; +import UserWebview from '../../services/plugins/UserWebview'; +import Logger from '@joplin/utils/Logger'; +import usePluginEditorView from './utils/usePluginEditorView'; import { stateUtils } from '@joplin/lib/reducer'; import { WindowIdContext } from '../NewWindowOrIFrame'; +import { EditorActivationCheckFilterObject } from '@joplin/lib/services/plugins/api/types'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +import WebviewController from '@joplin/lib/services/plugins/WebviewController'; +import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue'; + const debounce = require('debounce'); +const logger = Logger.create('NoteEditor'); + const commands = [ require('./commands/showRevisions'), ]; @@ -64,6 +73,15 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance()); const onDragOver: React.DragEventHandler = event => event.preventDefault(); let editorIdCounter = 0; +const makeNoteUpdateAction = (shownEditorViewIds: string[]) => { + return async () => { + for (const viewId of shownEditorViewIds) { + const controller = PluginService.instance().viewControllerByViewId(viewId) as WebviewController; + if (controller) controller.emitUpdate(); + } + }; +}; + function NoteEditorContent(props: NoteEditorProps) { const [showRevisions, setShowRevisions] = useState(false); const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false); @@ -73,6 +91,9 @@ function NoteEditorContent(props: NoteEditorProps) { const titleInputRef = useRef(); const isMountedRef = useRef(true); const noteSearchBarRef = useRef(null); + const viewUpdateAsyncQueue_ = useRef(new AsyncActionQueue(100, IntervalType.Fixed)); + + const shownEditorViewIds = props['plugins.shownEditorViewIds']; // Should be constant and unique to this instance of the editor. const editorId = useMemo(() => { @@ -94,6 +115,29 @@ function NoteEditorContent(props: NoteEditorProps) { const effectiveNoteId = useEffectiveNoteId(props); + useAsyncEffect(async (event) => { + if (!props.startupPluginsLoaded) return; + + let filterObject: EditorActivationCheckFilterObject = { + activatedEditors: [], + }; + filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject); + if (event.cancelled) return; + + for (const editor of filterObject.activatedEditors) { + const controller = PluginService.instance().pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController; + controller.setActive(editor.isActive); + } + }, [effectiveNoteId, props.startupPluginsLoaded]); + + useEffect(() => { + if (!props.startupPluginsLoaded) return; + viewUpdateAsyncQueue_.current.push(makeNoteUpdateAction(shownEditorViewIds)); + }, [effectiveNoteId, shownEditorViewIds, props.startupPluginsLoaded]); + + const { editorPlugin, editorView } = usePluginEditorView(props.plugins, shownEditorViewIds); + const builtInEditorVisible = !editorPlugin; + const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({ noteId: effectiveNoteId, isProvisional: props.isProvisional, @@ -101,6 +145,7 @@ function NoteEditorContent(props: NoteEditorProps) { editorRef: editorRef, onBeforeLoad: formNote_beforeLoad, onAfterLoad: formNote_afterLoad, + builtInEditorVisible, editorId, }); setFormNoteRef.current = setFormNote; @@ -186,7 +231,7 @@ function NoteEditorContent(props: NoteEditorProps) { // trigger onChange events, for example the textarea might be cleared. // We need to ignore these events, otherwise the note is going to be saved // with an invalid body. - reg.logger().debug('Skipping change event because the component is unmounted'); + logger.debug('Skipping change event because the component is unmounted'); return; } @@ -456,16 +501,18 @@ function NoteEditorContent(props: NoteEditorProps) { let editor = null; - if (props.bodyEditor === 'TinyMCE') { - editor = ; - } else if (props.bodyEditor === 'PlainText') { - editor = ; - } else if (props.bodyEditor === 'CodeMirror5') { - editor = ; - } else if (props.bodyEditor === 'CodeMirror6') { - editor = ; - } else { - throw new Error(`Invalid editor: ${props.bodyEditor}`); + if (builtInEditorVisible) { + if (props.bodyEditor === 'TinyMCE') { + editor = ; + } else if (props.bodyEditor === 'PlainText') { + editor = ; + } else if (props.bodyEditor === 'CodeMirror5') { + editor = ; + } else if (props.bodyEditor === 'CodeMirror6') { + editor = ; + } else { + throw new Error(`Invalid editor: ${props.bodyEditor}`); + } } const noteRevisionViewer_onBack = useCallback(() => { @@ -592,6 +639,23 @@ function NoteEditorContent(props: NoteEditorProps) { } } + const renderPluginEditor = () => { + if (!editorPlugin) return null; + + const html = props.pluginHtmlContents[editorPlugin.id]?.[editorView.id] ?? ''; + + return ; + }; + if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) { return renderNoNotes(styles.root); } @@ -616,6 +680,7 @@ function NoteEditorContent(props: NoteEditorProps) { {renderSearchInfo()}
{editor} + {renderPluginEditor()}
{renderSearchBar()} @@ -667,6 +732,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { watchedResources: state.watchedResources, 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 7a9935a89c..eb6fae6a0b 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -1,6 +1,6 @@ import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; -import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer'; import { MarkupLanguage } from '@joplin/renderer'; import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types'; import { Dispatch } from 'redux'; @@ -55,9 +55,11 @@ export interface NoteEditorProps { shareCacheSetting: string; syncUserId: string; searchResults: ProcessResultsRow[]; - + pluginHtmlContents: PluginHtmlContents; + 'plugins.shownEditorViewIds': string[]; onTitleChange?: (title: string)=> void; bodyEditor: string; + startupPluginsLoaded: boolean; } export interface NoteBodyEditorRef { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts index 68b9cef3fa..5671b283a6 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.ts @@ -15,6 +15,7 @@ const defaultFormNoteProps: HookDependencies = { onBeforeLoad: () => { }, onAfterLoad: () => { }, editorId: 'editor', + builtInEditorVisible: false, }; describe('useFormNote', () => { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts index 7f60655bd5..a4c35d70e0 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useFormNote.ts @@ -31,6 +31,7 @@ export interface HookDependencies { editorRef: any; onBeforeLoad(event: OnLoadEvent): void; onAfterLoad(event: OnLoadEvent): void; + builtInEditorVisible: boolean; } type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote; @@ -67,10 +68,11 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean { } type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise; -const useRefreshFormNoteOnChange = (formNoteRef: RefObject, editorId: string, noteId: string, initNoteState: InitNoteStateCallback) => { +const useRefreshFormNoteOnChange = (formNoteRef: RefObject, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => { // Increasing the value of this counter cancels any ongoing note refreshes and starts // a new refresh. const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState(0); + const prevBuiltInEditorVisible = usePrevious(builtInEditorVisible); useQueuedAsyncEffect(async (event) => { if (formNoteRefreshScheduled <= 0) return; @@ -107,6 +109,15 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject, editorId: setFormNoteRefreshScheduled(formNoteRefreshScheduled + 1); }, [formNoteRefreshScheduled]); + // When switching from the plugin editor to the built-in editor, we refresh the note since the + // plugin may have modified it via the data API. + useEffect(() => { + if (prevBuiltInEditorVisible !== builtInEditorVisible && builtInEditorVisible) { + refreshFormNote(); + } + }, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]); + + useEffect(() => { if (!noteId) return ()=>{}; @@ -134,7 +145,9 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject, editorId: }; export default function useFormNote(dependencies: HookDependencies) { - const { noteId, editorId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad } = dependencies; + const { + noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, + } = dependencies; const [formNote, setFormNote] = useState(defaultFormNote()); const [isNewNote, setIsNewNote] = useState(false); @@ -195,7 +208,7 @@ export default function useFormNote(dependencies: HookDependencies) { return newFormNote; }, []); - useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState); + useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible); useEffect(() => { if (!noteId) { diff --git a/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts new file mode 100644 index 0000000000..d40b0d327a --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.ts @@ -0,0 +1,89 @@ +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 { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; + +const sampleView = (): PluginViewState => { + return { + buttons: [], + containerType: ContainerType.Editor, + id: 'view-1', + opened: true, + type: 'webview', + }; +}; + +describe('usePluginEditorView', () => { + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should return the plugin editor view if is opened', async () => { + const pluginStates: PluginStates = { + '0': { + contentScripts: {}, + id: '1', + views: { + 'view-0': { + ...sampleView(), + id: 'view-0', + containerType: ContainerType.Panel, + }, + }, + }, + '1': { + contentScripts: {}, + id: '1', + views: { + 'view-1': sampleView(), + }, + }, + }; + + { + const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + expect(test.result.current.editorPlugin.id).toBe('1'); + expect(test.result.current.editorView.id).toBe('view-1'); + test.unmount(); + } + + { + pluginStates['1'].views['view-1'].opened = false; + const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + expect(test.result.current.editorPlugin).toBeFalsy(); + test.unmount(); + } + }); + + it('should return a plugin editor view even if multiple editors are conflicting', async () => { + const pluginStates: PluginStates = { + '1': { + contentScripts: {}, + id: '1', + views: { + 'view-1': sampleView(), + }, + }, + '2': { + contentScripts: {}, + id: '2', + views: { + 'view-2': { + ...sampleView(), + id: 'view-2', + }, + }, + }, + }; + + { + const test = renderHook(() => usePluginEditorView(pluginStates, ['view-1'])); + 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 new file mode 100644 index 0000000000..0e45de7de3 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView'; + +// 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[]) => { + return useMemo(() => { + const { editorPlugin, editorView } = getActivePluginEditorView(plugins); + if (editorView) { + if (!shownEditorViewIds.includes(editorView.id)) return { editorPlugin: null, editorView: null }; + } + return { editorPlugin, editorView }; + }, [plugins, shownEditorViewIds]); +}; diff --git a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx index 1f2a894229..dc24442d66 100644 --- a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx +++ b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx @@ -7,6 +7,7 @@ import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseC import { connect } from 'react-redux'; import { buildStyle } from '@joplin/lib/theme'; import { _ } from '@joplin/lib/locale'; +import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView'; import { AppState } from '../../app.reducer'; interface NoteToolbarProps { @@ -49,13 +50,20 @@ interface ConnectProps { const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId }); + const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins); + + const commands = [ + 'showSpellCheckerMenu', + 'editAlarm', + 'toggleVisiblePanes', + 'showNoteProperties', + ]; + + if (editorPlugin) commands.push('toggleEditorPlugin'); + return { - toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([ - 'showSpellCheckerMenu', - 'editAlarm', - 'toggleVisiblePanes', - 'showNoteProperties', - ].concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'noteToolbar')), whenClauseContext), + toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commands + .concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'noteToolbar')), whenClauseContext), }; }; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts index f238c44bd8..707a7ae5ec 100644 --- a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.ts @@ -28,6 +28,7 @@ import * as restoreNote from './restoreNote'; import * as revealResourceFile from './revealResourceFile'; import * as search from './search'; import * as setTags from './setTags'; +import * as showEditorPlugin from './showEditorPlugin'; import * as showModalMessage from './showModalMessage'; import * as showNoteContentProperties from './showNoteContentProperties'; import * as showNoteProperties from './showNoteProperties'; @@ -35,6 +36,7 @@ import * as showPrompt from './showPrompt'; import * as showShareFolderDialog from './showShareFolderDialog'; import * as showShareNoteDialog from './showShareNoteDialog'; import * as showSpellCheckerMenu from './showSpellCheckerMenu'; +import * as toggleEditorPlugin from './toggleEditorPlugin'; import * as toggleEditors from './toggleEditors'; import * as toggleLayoutMoveMode from './toggleLayoutMoveMode'; import * as toggleMenuBar from './toggleMenuBar'; @@ -76,6 +78,7 @@ const index: any[] = [ revealResourceFile, search, setTags, + showEditorPlugin, showModalMessage, showNoteContentProperties, showNoteProperties, @@ -83,6 +86,7 @@ const index: any[] = [ showShareFolderDialog, showShareNoteDialog, showSpellCheckerMenu, + toggleEditorPlugin, toggleEditors, toggleLayoutMoveMode, toggleMenuBar, diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.ts new file mode 100644 index 0000000000..6a12cc2318 --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.ts @@ -0,0 +1,53 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import Setting from '@joplin/lib/models/Setting'; +import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView'; +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('showEditorPlugin'); + +export const declaration: CommandDeclaration = { + name: 'showEditorPlugin', + label: () => 'Show editor plugin', + iconName: 'fas fa-eye', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext, editorViewId = '', show = true) => { + logger.info('View:', editorViewId, 'Show:', show); + + const shownEditorViewIds = Setting.value('plugins.shownEditorViewIds'); + + if (!editorViewId) { + const { editorPlugin, editorView } = getActivePluginEditorView(context.state.pluginService.plugins); + + if (!editorPlugin) { + logger.warn('No editor plugin to toggle to'); + return; + } + + 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); + } + + Setting.setValue('plugins.shownEditorViewIds', shownEditorViewIds); + }, + }; +}; diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.ts b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.ts new file mode 100644 index 0000000000..5bde8aea8a --- /dev/null +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.ts @@ -0,0 +1,37 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import Setting from '@joplin/lib/models/Setting'; +import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView'; +import Logger from '@joplin/utils/Logger'; + +const logger = Logger.create('toggleEditorPlugin'); + +export const declaration: CommandDeclaration = { + name: 'toggleEditorPlugin', + label: () => _('Toggle editor plugin'), + iconName: 'fas fa-eye', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (context: CommandContext) => { + const shownEditorViewIds = Setting.value('plugins.shownEditorViewIds'); + const { editorPlugin, editorView } = getActivePluginEditorView(context.state.pluginService.plugins); + + if (!editorPlugin) { + logger.warn('No editor plugin to toggle to'); + return; + } + + const idx = shownEditorViewIds.indexOf(editorView.id); + + if (idx < 0) { + shownEditorViewIds.push(editorView.id); + } else { + shownEditorViewIds.splice(idx, 1); + } + + Setting.setValue('plugins.shownEditorViewIds', shownEditorViewIds); + }, + }; +}; diff --git a/packages/app-desktop/gui/hooks/usePrevious.ts b/packages/app-desktop/gui/hooks/usePrevious.ts index 1b4828bb2d..4ace30dbef 100644 --- a/packages/app-desktop/gui/hooks/usePrevious.ts +++ b/packages/app-desktop/gui/hooks/usePrevious.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export default function usePrevious(value: any, initialValue: any = null): any { +export default function usePrevious(value: T, initialValue: T = null): T { const ref = useRef(initialValue); useEffect(() => { ref.current = value; diff --git a/packages/app-desktop/gui/styles/toolbar-button.scss b/packages/app-desktop/gui/styles/toolbar-button.scss index 3cb32edfa0..aa3a7d3fc3 100644 --- a/packages/app-desktop/gui/styles/toolbar-button.scss +++ b/packages/app-desktop/gui/styles/toolbar-button.scss @@ -24,6 +24,10 @@ overflow: hidden; text-overflow: ellipsis; + > .toolbar-icon { + font-size: 16px; + } + &.-has-title { width: auto; max-width: unset; diff --git a/packages/lib/eventManager.ts b/packages/lib/eventManager.ts index de762e8822..21fd67bba2 100644 --- a/packages/lib/eventManager.ts +++ b/packages/lib/eventManager.ts @@ -137,7 +137,13 @@ export class EventManager { // deep equality check to see if it's been changed. Normally the // filter objects should be relatively small so there shouldn't be // much of a performance hit. - const newOutput = await listener(output); + let newOutput = null; + try { + newOutput = await listener(output); + } catch (error) { + error.message = `Error in listener when calling: ${filterName}: ${error.message}`; + throw error; + } // Plugin didn't return anything - so we leave the object as it is. if (newOutput === undefined) continue; diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts index 2ba224b2e1..c7cf7edf81 100644 --- a/packages/lib/models/settings/builtInMetadata.ts +++ b/packages/lib/models/settings/builtInMetadata.ts @@ -926,6 +926,12 @@ const builtInMetadata = (Setting: typeof SettingType) => { storage: SettingStorage.File, }, + 'plugins.shownEditorViewIds': { + value: [] as string[], + type: SettingItemType.Array, + public: false, + }, + // Deprecated - use markdown.plugin.* 'markdown.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] }, 'markdown.typographer': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] }, diff --git a/packages/lib/services/plugins/Plugin.ts b/packages/lib/services/plugins/Plugin.ts index 12988f8eaa..1d6c09bec8 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 hasViewController(handle: ViewHandle) { + return !!this.viewControllers_[handle]; + } + public viewController(handle: ViewHandle): ViewController { if (!this.viewControllers_[handle]) throw new Error(`View not found: ${handle}`); return this.viewControllers_[handle]; diff --git a/packages/lib/services/plugins/PluginService.ts b/packages/lib/services/plugins/PluginService.ts index 083c61dff2..a2a103e32a 100644 --- a/packages/lib/services/plugins/PluginService.ts +++ b/packages/lib/services/plugins/PluginService.ts @@ -14,6 +14,7 @@ import isCompatible from './utils/isCompatible'; import { AppType } from './api/types'; import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform'; import { _ } from '../../locale'; +import ViewController from './ViewController'; const uslug = require('@joplin/fork-uslug'); const logger = Logger.create('PluginService'); @@ -202,6 +203,13 @@ export default class PluginService extends BaseService { return this.plugins_[id]; } + public viewControllerByViewId(id: string): ViewController|null { + for (const [, plugin] of Object.entries(this.plugins_)) { + if (plugin.hasViewController(id)) return plugin.viewController(id); + } + return null; + } + public unserializePluginSettings(settings: SerializedPluginSettings): PluginSettings { const output = { ...settings }; diff --git a/packages/lib/services/plugins/WebviewController.ts b/packages/lib/services/plugins/WebviewController.ts index 5b94697c51..f7bc650c8c 100644 --- a/packages/lib/services/plugins/WebviewController.ts +++ b/packages/lib/services/plugins/WebviewController.ts @@ -5,10 +5,15 @@ const { toSystemSlashes } = require('../../path-utils'); import PostMessageService, { MessageParticipant } from '../PostMessageService'; import { PluginViewState } from './reducer'; import { defaultWindowId } from '../../reducer'; +import Logger from '@joplin/utils/Logger'; +import CommandService from '../CommandService'; + +const logger = Logger.create('WebviewController'); export enum ContainerType { Panel = 'panel', Dialog = 'dialog', + Editor = 'editor', } export interface Options { @@ -49,12 +54,15 @@ 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 closeResponse_: CloseResponse = null; + private containerType_: ContainerType = 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) { super(handle, pluginId, store); this.baseDir_ = toSystemSlashes(baseDir, 'linux'); + this.containerType_ = containerType; const view: PluginViewState = { id: this.handle, @@ -135,18 +143,38 @@ export default class WebviewController extends ViewController { } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public async emitMessage(event: EmitMessageEvent): Promise { - + 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() { + if (!this.updateListener_) return; + + if (this.containerType_ === ContainerType.Editor && (!this.isActive() || !this.isVisible())) { + logger.info('emitMessage: Not emitting update because editor is disabled or hidden:', this.pluginId, this.handle, this.isActive(), this.isVisible()); + return; + } + + this.updateListener_(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied public onMessage(callback: any) { this.messageListener_ = callback; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + public onUpdate(callback: any) { + this.updateListener_ = callback; + } + // --------------------------------------------- // Specific to panels // --------------------------------------------- @@ -238,4 +266,27 @@ export default class WebviewController extends ViewController { public set fitToContent(fitToContent: boolean) { this.setStoreProp('fitToContent', fitToContent); } + + // --------------------------------------------- + // Specific to editors + // --------------------------------------------- + + public setActive(active: boolean) { + this.setStoreProp('opened', active); + } + + public isActive(): boolean { + return this.storeView.opened; + } + + public async isVisible(): Promise { + if (!this.storeView.opened) return false; + const shownEditorViewIds: string[] = this.store.getState().settings['plugins.shownEditorViewIds']; + return shownEditorViewIds.includes(this.handle); + } + + public async setVisible(visible: boolean) { + await CommandService.instance().execute('showEditorPlugin', this.handle, visible); + } + } diff --git a/packages/lib/services/plugins/api/JoplinViews.ts b/packages/lib/services/plugins/api/JoplinViews.ts index 4f0729a0d8..9b9eebc8a1 100644 --- a/packages/lib/services/plugins/api/JoplinViews.ts +++ b/packages/lib/services/plugins/api/JoplinViews.ts @@ -7,6 +7,7 @@ import JoplinViewsMenus from './JoplinViewsMenus'; import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons'; import JoplinViewsPanels from './JoplinViewsPanels'; import JoplinViewsNoteList from './JoplinViewsNoteList'; +import JoplinViewsEditors from './JoplinViewsEditor'; /** * This namespace provides access to view-related services. @@ -25,6 +26,7 @@ export default class JoplinViews { private menus_: JoplinViewsMenus = null; private toolbarButtons_: JoplinViewsToolbarButtons = null; private dialogs_: JoplinViewsDialogs = null; + private editors_: JoplinViewsEditors = null; private noteList_: JoplinViewsNoteList = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private implementation_: any = null; @@ -46,6 +48,11 @@ export default class JoplinViews { return this.panels_; } + public get editors() { + if (!this.editors_) this.editors_ = new JoplinViewsEditors(this.plugin, this.store); + return this.editors_; + } + public get menuItems() { if (!this.menuItems_) this.menuItems_ = new JoplinViewsMenuItems(this.plugin, this.store); return this.menuItems_; diff --git a/packages/lib/services/plugins/api/JoplinViewsEditor.ts b/packages/lib/services/plugins/api/JoplinViewsEditor.ts new file mode 100644 index 0000000000..5ded8d3285 --- /dev/null +++ b/packages/lib/services/plugins/api/JoplinViewsEditor.ts @@ -0,0 +1,152 @@ +/* eslint-disable multiline-comment-style */ + +import eventManager from '../../../eventManager'; +import Plugin from '../Plugin'; +import createViewHandle from '../utils/createViewHandle'; +import WebviewController, { ContainerType } from '../WebviewController'; +import { ActivationCheckCallback, EditorActivationCheckFilterObject, FilterHandler, ViewHandle, UpdateCallback } from './types'; + +/** + * Allows creating alternative note editors. You can create a view to handle loading and saving the + * note, and do your own rendering. + * + * Although it may be used to implement an alternative text editor, the more common use case may be + * to render the note in a different, graphical way - for example displaying a graph, and + * saving/loading the graph data in the associated note. In that case, you would detect whether the + * current note contains graph data and, in this case, you'd display your viewer. + * + * Terminology: An editor is **active** when it can be used to edit the current note. Note that it + * doesn't necessarily mean that your editor is visible - it just means that the user has the option + * to switch to it (via the "toggle editor" button). A **visible** editor is active and is currently + * being displayed. + * + * To implement an editor you need to listen to two events: + * + * - `onActivationCheck`: This is a way for the app to know whether your editor should be active or + * not. Return `true` from this handler to activate your editor. + * + * - `onUpdate`: When this is called you should update your editor based on the current note + * content. Call `joplin.workspace.selectedNote()` to get the current note. + * + * - `showEditorPlugin` and `toggleEditorPlugin` commands. Additionally you can use these commands + * to display your editor via `joplin.commands.execute('showEditorPlugin')`. This is not always + * necessary since the user can switch to your editor using the "toggle editor" button, however + * you may want to programmatically display the editor in some cases - for example when creating a + * new note specific to your editor. + * + * Note that only one editor view can be active at a time. This is why it is important not to + * activate your view if it's not relevant to the current note. If more than one is active, it is + * undefined which editor is going to be used to display the note. + * + * For an example of editor plugin, see the [YesYouKan + * plugin](https://github.com/joplin/plugin-yesyoukan/blob/master/src/index.ts). In particular, + * check the logic around `onActivationCheck` and `onUpdate` since this is the entry points for + * using this API. + */ +export default class JoplinViewsEditors { + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + private store: any; + private plugin: Plugin; + private activationCheckHandlers_: Record> = {}; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + public constructor(plugin: Plugin, store: any) { + this.store = store; + this.plugin = plugin; + } + + private controller(handle: ViewHandle): WebviewController { + return this.plugin.viewController(handle) as WebviewController; + } + + /** + * Creates a new editor view + */ + 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; + } + + /** + * Sets the editor HTML content + */ + public async setHtml(handle: ViewHandle, html: string): Promise { + return this.controller(handle).html = html; + } + + /** + * Adds and loads a new JS or CSS file into the panel. + */ + public async addScript(handle: ViewHandle, scriptPath: string): Promise { + return this.controller(handle).addScript(scriptPath); + } + + /** + * See [[JoplinViewPanels]] + */ + // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied + public async onMessage(handle: ViewHandle, callback: Function): Promise { + return this.controller(handle).onMessage(callback); + } + + /** + * Emitted when the editor can potentially be activated - this for example when the current note + * is changed, or when the application is opened. At that point should can check the current + * note and decide whether your editor should be activated or not. If it should return `true`, + * otherwise return `false`. + */ + public async onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise { + const handler: FilterHandler = async (object) => { + const isActive = await callback(); + object.activatedEditors.push({ + pluginId: this.plugin.id, + viewId: handle, + isActive: isActive, + }); + return object; + }; + + this.activationCheckHandlers_[handle] = handler; + + eventManager.filterOn('editorActivationCheck', this.activationCheckHandlers_[handle]); + this.plugin.addOnUnloadListener(() => { + eventManager.filterOff('editorActivationCheck', this.activationCheckHandlers_[handle]); + }); + } + + /** + * Emitted when the editor content should be updated. This for example when the currently + * selected note changes, or when the user makes the editor visible. + */ + public async onUpdate(handle: ViewHandle, callback: UpdateCallback): Promise { + this.controller(handle).onUpdate(callback); + } + + /** + * See [[JoplinViewPanels]] + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied + public postMessage(handle: ViewHandle, message: any): void { + return this.controller(handle).postMessage(message); + } + + /** + * Tells whether the editor is active or not. + */ + public async isActive(handle: ViewHandle): Promise { + return this.controller(handle).visible; + } + + /** + * Tells whether the editor is effectively visible or not. If the editor is inactive, this will + * return `false`. If the editor is active and the user has switched to it, it will return + * `true`. Otherwise it will return `false`. + */ + public async isVisible(handle: ViewHandle): Promise { + return this.controller(handle).isVisible(); + } + +} diff --git a/packages/lib/services/plugins/api/JoplinViewsPanels.ts b/packages/lib/services/plugins/api/JoplinViewsPanels.ts index 839d9ebaf3..8a8bcf8444 100644 --- a/packages/lib/services/plugins/api/JoplinViewsPanels.ts +++ b/packages/lib/services/plugins/api/JoplinViewsPanels.ts @@ -130,4 +130,8 @@ export default class JoplinViewsPanels { return this.controller(handle).visible; } + 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 07fce360cc..3b10a746ad 100644 --- a/packages/lib/services/plugins/api/JoplinWorkspace.ts +++ b/packages/lib/services/plugins/api/JoplinWorkspace.ts @@ -6,7 +6,7 @@ import eventManager, { EventName } from '../../../eventManager'; import Setting from '../../../models/Setting'; import { FolderEntity } from '../../database/types'; import makeListener from '../utils/makeListener'; -import { Disposable, MenuItem } from './types'; +import { Disposable, EditContextMenuFilterObject, FilterHandler } from './types'; /** * @ignore @@ -18,12 +18,6 @@ import Note from '../../../models/Note'; */ import Folder from '../../../models/Folder'; -export interface EditContextMenuFilterObject { - items: MenuItem[]; -} - -type FilterHandler = (object: T)=> Promise; - enum ItemChangeEventType { Create = 1, Update = 2, diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index a51b7fad1f..30ec0022f0 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -384,6 +384,26 @@ export interface Rectangle { height?: number; } +export type ActivationCheckCallback = ()=> Promise; + +export type UpdateCallback = ()=> Promise; + +export type VisibleHandler = ()=> Promise; + +export interface EditContextMenuFilterObject { + items: MenuItem[]; +} + +export interface EditorActivationCheckFilterObject { + activatedEditors: { + pluginId: string; + viewId: string; + isActive: boolean; + }[]; +} + +export type FilterHandler = (object: T)=> Promise; + // ================================================================= // Settings types // ================================================================= diff --git a/packages/lib/services/plugins/reducer.ts b/packages/lib/services/plugins/reducer.ts index 72ca6a9f89..de9a3be736 100644 --- a/packages/lib/services/plugins/reducer.ts +++ b/packages/lib/services/plugins/reducer.ts @@ -5,6 +5,10 @@ import { ButtonSpec } from './api/types'; export interface PluginViewState { 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; @@ -28,7 +32,7 @@ interface PluginContentScriptStates { [type: string]: PluginContentScriptState[]; } -interface PluginState { +export interface PluginState { id: string; contentScripts: PluginContentScriptStates; views: PluginViewStates; diff --git a/packages/lib/services/plugins/utils/getActivePluginEditorView.ts b/packages/lib/services/plugins/utils/getActivePluginEditorView.ts new file mode 100644 index 0000000000..72dd537e4b --- /dev/null +++ b/packages/lib/services/plugins/utils/getActivePluginEditorView.ts @@ -0,0 +1,28 @@ +import Logger from '@joplin/utils/Logger'; +import { PluginState, PluginStates, PluginViewState } from '../reducer'; +import { ContainerType } from '../WebviewController'; + +const logger = Logger.create('getActivePluginEditorView'); + +interface Output { + editorPlugin: PluginState; + editorView: PluginViewState; +} + +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 }; + } + } + } + } + + return output; +}; +