You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Plugins: Add support for editor plugins (#11296)
This commit is contained in:
		| @@ -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
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -637,6 +637,7 @@ class MainScreenComponent extends React.Component<Props, State> { | ||||
| 					<NoteEditor | ||||
| 						windowId={defaultWindowId} | ||||
| 						key={key} | ||||
| 						startupPluginsLoaded={this.props.startupPluginsLoaded} | ||||
| 					/> | ||||
| 				</div>; | ||||
| 			}, | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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'; | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -15,6 +15,7 @@ const defaultFormNoteProps: HookDependencies = { | ||||
| 	onBeforeLoad: () => { }, | ||||
| 	onAfterLoad: () => { }, | ||||
| 	editorId: 'editor', | ||||
| 	builtInEditorVisible: false, | ||||
| }; | ||||
|  | ||||
| describe('useFormNote', () => { | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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(); | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
| @@ -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]); | ||||
| }; | ||||
| @@ -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), | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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); | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
| @@ -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); | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
| @@ -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; | ||||
|   | ||||
| @@ -24,6 +24,10 @@ | ||||
| 	overflow: hidden; | ||||
| 	text-overflow: ellipsis; | ||||
|  | ||||
| 	> .toolbar-icon { | ||||
| 		font-size: 16px; | ||||
| 	} | ||||
|  | ||||
| 	&.-has-title { | ||||
| 		width: auto; | ||||
| 		max-width: unset; | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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] }, | ||||
|   | ||||
| @@ -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]; | ||||
|   | ||||
| @@ -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 }; | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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_; | ||||
|   | ||||
							
								
								
									
										152
									
								
								packages/lib/services/plugins/api/JoplinViewsEditor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								packages/lib/services/plugins/api/JoplinViewsEditor.ts
									
									
									
									
									
										Normal 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(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @@ -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(); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
| // ================================================================= | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|  | ||||
		Reference in New Issue
	
	Block a user