1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-03 23:50:33 +02:00

Desktop: Resolves #11687: Plugins: Allow editor plugins to support multiple windows (#12041)

This commit is contained in:
Henry Heino
2025-06-06 02:00:47 -07:00
committed by GitHub
parent 291ba88224
commit 608dbab453
46 changed files with 1022 additions and 195 deletions

View File

@ -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

6
.gitignore vendored
View File

@ -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

View File

@ -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',

View File

@ -63,7 +63,6 @@ export interface NoteEditorProps {
syncUserId: string;
searchResults: ProcessResultsRow[];
pluginHtmlContents: PluginHtmlContents;
'plugins.shownEditorViewIds': string[];
onTitleChange?: (title: string)=> void;
bodyEditor: string;
startupPluginsLoaded: boolean;

View File

@ -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<void>;
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<FormNote|null>(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;

View File

@ -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();

View File

@ -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]);
};

View File

@ -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',

View File

@ -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);
}

View File

@ -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);
});
});

View File

@ -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,
'<code>Loaded!</code>',
);
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');
},
});

View File

@ -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,
`<div id="frame-summary">
Editor plugin:
<code id="view-id-base">${editorViewId}</code>
for view handle: <code>${encodeURI(view)}</code>
</div>`,
);
},
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');
},
});

View File

@ -70,17 +70,20 @@ export const test = base.extend<JoplinFixtures>({
// 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<JoplinFixtures>({
await use(electronApp);
// For debugging purposes, attach the Joplin log file to the test:
await attachJoplinLog(profileDirectory, testInfo);
await electronApp.firstWindow();
await electronApp.close();
},

View File

@ -25,9 +25,13 @@ export default function(webviewRef: RefObject<HTMLIFrameElement>, isReady: boole
}
if (event.data.target === 'postMessageService.registerViewMessageHandler') {
PostMessageService.instance().registerViewMessageHandler(ResponderComponentType.UserWebview, viewId, (message: MessageResponse) => {
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,

View File

@ -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';

View File

@ -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';

View File

@ -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.

View File

@ -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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> 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<ComponentProps, State> imp
}
const renderPluginEditor = () => {
this.editorPluginHandler_.onEditorPluginShown(editorView.id);
return <PluginUserWebView
viewInfo={{ plugin: editorPlugin, view: editorView }}
themeId={this.props.themeId}
@ -1604,6 +1627,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> 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<ComponentProps, State> imp
return result;
};
const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins);
const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins, this.props.windowId);
return (
<View style={this.rootStyle(this.props.themeId).root}>
@ -1709,13 +1733,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
// how the new note should be rendered
const NoteScreenWrapper = (props: Props) => {
const dialogs = useContext(DialogContext);
const visibleEditorPluginIds = useVisiblePluginEditorViewIds(props.plugins, props.windowId);
return (
<NoteScreenComponent key={props.noteId} dialogs={dialogs} {...props} />
<NoteScreenComponent key={props.noteId} dialogs={dialogs} visibleEditorPluginIds={visibleEditorPluginIds} {...props} />
);
};
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,

View File

@ -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

View File

@ -42,9 +42,11 @@ export default class JoplinSettings {
*/
values(keys: string[] | string): Promise<Record<string, unknown>>;
/**
* @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<any>;
/**

View File

@ -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;

View File

@ -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<void>;
/**
* Creates a new editor view
*
* @deprecated
*/
create(id: string): Promise<ViewHandle>;
/**
@ -59,11 +80,18 @@ export default class JoplinViewsEditors {
* See [[JoplinViewPanels]]
*/
onMessage(handle: ViewHandle, callback: Function): Promise<void>;
/**
* Saves the content of the editor, without calling `onUpdate` for editors in the same window.
*/
saveNote(handle: ViewHandle, props: SaveNoteOptions): Promise<void>;
/**
* 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<void>;
/**
@ -86,3 +114,4 @@ export default class JoplinViewsEditors {
*/
isVisible(handle: ViewHandle): Promise<boolean>;
}
export {};

View File

@ -80,5 +80,9 @@ export default class JoplinViewsPanels {
* Tells whether the panel is visible or not
*/
visible(handle: ViewHandle): Promise<boolean>;
/**
* 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<boolean>;
}

View File

@ -80,6 +80,8 @@ export default class JoplinWorkspace {
filterEditorContextMenu(handler: FilterHandler<EditContextMenuFilterObject>): 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<any>;
/**
@ -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<string[]>;
/**
* 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<string>;
}
export {};

View File

@ -397,9 +397,40 @@ export interface Rectangle {
height?: number;
}
export type ActivationCheckCallback = ()=> Promise<boolean>;
export interface EditorUpdateEvent {
newBody: string;
noteId: string;
}
export type UpdateCallback = (event: EditorUpdateEvent)=> Promise<void>;
export type UpdateCallback = ()=> Promise<void>;
export interface ActivationCheckEvent {
handle: ViewHandle;
noteId: string;
}
export type ActivationCheckCallback = (event: ActivationCheckEvent)=> Promise<boolean>;
/**
* 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<void>;
}
export type VisibleHandler = ()=> Promise<void>;
@ -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<T> = (object: T)=> Promise<T>;
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
// =================================================================

View File

@ -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}`);
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}`);
}
shownEditorViewIds.push(editorViewId);
} else {
if (idx < 0) {
const previousVisible = editorView.parentWindowId === windowId && controller.isVisible();
if (show && previousVisible) {
logger.info(`Editor is already visible: ${editorViewId}`);
return;
} else if (!show && !previousVisible) {
logger.info(`Editor is already hidden: ${editorViewId}`);
return;
}
shownEditorViewIds.splice(idx, 1);
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());
logger.info('Shown editor IDs:', shownEditorViewIds);
Setting.setValue('plugins.shownEditorViewIds', shownEditorViewIds);
controller.setOpened(show);
},
};
};

View File

@ -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({

View File

@ -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,
});
}
};

View File

@ -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 = {

View File

@ -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');

View File

@ -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;

View File

@ -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 || {};

View File

@ -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[]) {
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_, 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;
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,
});
}
}

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<EditorActivationCheckFilterObject, 'effectiveNoteId'|'windowId'|'activatedEditors'>;
/**
* 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<string, FilterHandler<EditorActivationCheckFilterObject>> = {};
private unhandledActivationCheck_: Map<string, ActivationCheckSlice> = 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<EditorActivationCheckFilterObject> = 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<ViewHandle> {
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<ViewHandle>(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<void> {
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<void> {
const handler: FilterHandler<EditorActivationCheckFilterObject> = 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<boolean> {
return this.controller(handle).visible;
return this.controller(handle).isActive();
}
/**

View File

@ -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<boolean> {
return this.controller(handle).isActive();
}

View File

@ -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<any> {

View File

@ -397,9 +397,40 @@ export interface Rectangle {
height?: number;
}
export type ActivationCheckCallback = ()=> Promise<boolean>;
export interface EditorUpdateEvent {
newBody: string;
noteId: string;
}
export type UpdateCallback = (event: EditorUpdateEvent)=> Promise<void>;
export type UpdateCallback = ()=> Promise<void>;
export interface ActivationCheckEvent {
handle: ViewHandle;
noteId: string;
}
export type ActivationCheckCallback = (event: ActivationCheckEvent)=> Promise<boolean>;
/**
* 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<void>;
}
export type VisibleHandler = ()=> Promise<void>;
@ -408,6 +439,8 @@ export interface EditContextMenuFilterObject {
}
export interface EditorActivationCheckFilterObject {
effectiveNoteId: string;
windowId: string;
activatedEditors: {
pluginId: string;
viewId: string;

View File

@ -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<any>, 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') {

View File

@ -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);
if (allActiveViews.length === 0) {
return { editorPlugin: null, editorView: null };
}
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 };
}
}
}
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 output;
return allActiveViews[0];
};

View File

@ -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;
};

View File

@ -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];
};

View File

@ -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);
};

View File

@ -2,7 +2,7 @@
"extends": "../../tsconfig.json",
"include": [
"**/*.ts",
"**/*.tsx", "../app-desktop/commands/emptyTrash.ts",
"**/*.tsx"
],
"exclude": [
"**/node_modules",