1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Plugins: Add support for editor plugins (#11296)

This commit is contained in:
Laurent Cozic 2024-11-10 14:04:46 +00:00 committed by GitHub
parent 49e86d116f
commit f091c32992
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 630 additions and 38 deletions

View File

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

6
.gitignore vendored
View File

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

View File

@ -637,6 +637,7 @@ class MainScreenComponent extends React.Component<Props, State> {
<NoteEditor
windowId={defaultWindowId}
key={key}
startupPluginsLoaded={this.props.startupPluginsLoaded}
/>
</div>;
},

View File

@ -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> = props => {
<NoteEditor
windowId={props.windowId}
onTitleChange={onNoteTitleChange}
startupPluginsLoaded={props.startupPluginsLoaded}
/>
</div>;
@ -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);

View File

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

View File

@ -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<HTMLInputElement>();
const isMountedRef = useRef(true);
const noteSearchBarRef = useRef(null);
const viewUpdateAsyncQueue_ = useRef<AsyncActionQueue>(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 = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'PlainText') {
editor = <PlainEditor {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror5') {
editor = <CodeMirror5 {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror6') {
editor = <CodeMirror6 {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
if (builtInEditorVisible) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'PlainText') {
editor = <PlainEditor {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror5') {
editor = <CodeMirror5 {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror6') {
editor = <CodeMirror6 {...editorProps}/>;
} 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 <UserWebview
key={editorView.id}
viewId={editorView.id}
themeId={props.themeId}
html={html}
scripts={editorView.scripts}
pluginId={editorPlugin.id}
borderBottom={true}
fitToContent={false}
/>;
};
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
return renderNoNotes(styles.root);
}
@ -616,6 +680,7 @@ function NoteEditorContent(props: NoteEditorProps) {
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
{editor}
{renderPluginEditor()}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{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',

View File

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

View File

@ -15,6 +15,7 @@ const defaultFormNoteProps: HookDependencies = {
onBeforeLoad: () => { },
onAfterLoad: () => { },
editorId: 'editor',
builtInEditorVisible: false,
};
describe('useFormNote', () => {

View File

@ -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<FormNote>;
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback) => {
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, 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<number>(0);
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
useQueuedAsyncEffect(async (event) => {
if (formNoteRefreshScheduled <= 0) return;
@ -107,6 +109,15 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, 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<FormNote>, 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<FormNote>(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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T>(value: T, initialValue: T = null): T {
const ref = useRef(initialValue);
useEffect(() => {
ref.current = value;

View File

@ -24,6 +24,10 @@
overflow: hidden;
text-overflow: ellipsis;
> .toolbar-icon {
font-size: 16px;
}
&.-has-title {
width: auto;
max-width: unset;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, FilterHandler<EditorActivationCheckFilterObject>> = {};
// 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<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;
}
/**
* Sets the editor HTML content
*/
public async setHtml(handle: ViewHandle, html: string): Promise<string> {
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<void> {
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<void> {
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<void> {
const handler: FilterHandler<EditorActivationCheckFilterObject> = 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<void> {
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<boolean> {
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<boolean> {
return this.controller(handle).isVisible();
}
}

View File

@ -130,4 +130,8 @@ export default class JoplinViewsPanels {
return this.controller(handle).visible;
}
public async isActive(handle: ViewHandle): Promise<boolean> {
return this.controller(handle).isActive();
}
}

View File

@ -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<T> = (object: T)=> Promise<void>;
enum ItemChangeEventType {
Create = 1,
Update = 2,

View File

@ -384,6 +384,26 @@ export interface Rectangle {
height?: number;
}
export type ActivationCheckCallback = ()=> Promise<boolean>;
export type UpdateCallback = ()=> Promise<void>;
export type VisibleHandler = ()=> Promise<void>;
export interface EditContextMenuFilterObject {
items: MenuItem[];
}
export interface EditorActivationCheckFilterObject {
activatedEditors: {
pluginId: string;
viewId: string;
isActive: boolean;
}[];
}
export type FilterHandler<T> = (object: T)=> Promise<T>;
// =================================================================
// Settings types
// =================================================================

View File

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

View File

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