You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Add new beta Markdown editor based on CodeMirror 6 (#8793)
This commit is contained in:
		| @@ -215,12 +215,11 @@ packages/app-desktop/gui/MenuBar.js | ||||
| packages/app-desktop/gui/MultiNoteActions.js | ||||
| packages/app-desktop/gui/Navigator.js | ||||
| packages/app-desktop/gui/NoteContentPropertiesDialog.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js | ||||
| @@ -232,6 +231,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js | ||||
| @@ -410,23 +416,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/theme.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/types.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js | ||||
| packages/app-mobile/components/NoteEditor/EditLinkDialog.js | ||||
| packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js | ||||
| @@ -439,7 +429,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js | ||||
| packages/app-mobile/components/NoteEditor/NoteEditor.test.js | ||||
| packages/app-mobile/components/NoteEditor/NoteEditor.js | ||||
| packages/app-mobile/components/NoteEditor/SearchPanel.js | ||||
| packages/app-mobile/components/NoteEditor/SelectionFormatting.js | ||||
| packages/app-mobile/components/NoteEditor/types.js | ||||
| packages/app-mobile/components/NoteList.js | ||||
| packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js | ||||
| @@ -489,6 +478,38 @@ packages/app-mobile/utils/fs-driver-rn.js | ||||
| packages/app-mobile/utils/setupNotifications.js | ||||
| packages/app-mobile/utils/shareHandler.js | ||||
| packages/app-mobile/utils/types.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js | ||||
| packages/editor/CodeMirror/CodeMirrorControl.test.js | ||||
| packages/editor/CodeMirror/CodeMirrorControl.js | ||||
| packages/editor/CodeMirror/PluginLoader.js | ||||
| packages/editor/CodeMirror/configFromSettings.js | ||||
| packages/editor/CodeMirror/createEditor.test.js | ||||
| packages/editor/CodeMirror/createEditor.js | ||||
| packages/editor/CodeMirror/editorCommands/editorCommands.js | ||||
| packages/editor/CodeMirror/editorCommands/supportsCommand.js | ||||
| packages/editor/CodeMirror/editorCommands/swapLine.js | ||||
| packages/editor/CodeMirror/getScrollFraction.js | ||||
| packages/editor/CodeMirror/markdown/computeSelectionFormatting.js | ||||
| packages/editor/CodeMirror/markdown/decoratorExtension.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.js | ||||
| packages/editor/CodeMirror/markdown/markdownMathParser.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownMathParser.js | ||||
| packages/editor/CodeMirror/markdown/markdownReformatter.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownReformatter.js | ||||
| packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js | ||||
| packages/editor/CodeMirror/testUtil/createEditorSettings.js | ||||
| packages/editor/CodeMirror/testUtil/createTestEditor.js | ||||
| packages/editor/CodeMirror/testUtil/forceFullParse.js | ||||
| packages/editor/CodeMirror/testUtil/loadLanguages.js | ||||
| packages/editor/CodeMirror/theme.js | ||||
| packages/editor/SelectionFormatting.js | ||||
| packages/editor/events.js | ||||
| packages/editor/types.js | ||||
| packages/fork-htmlparser2/src/CollectingHandler.js | ||||
| packages/fork-htmlparser2/src/FeedHandler.spec.js | ||||
| packages/fork-htmlparser2/src/FeedHandler.js | ||||
|   | ||||
							
								
								
									
										61
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										61
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -201,12 +201,11 @@ packages/app-desktop/gui/MenuBar.js | ||||
| packages/app-desktop/gui/MultiNoteActions.js | ||||
| packages/app-desktop/gui/Navigator.js | ||||
| packages/app-desktop/gui/NoteContentPropertiesDialog.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js | ||||
| @@ -218,6 +217,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js | ||||
| packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js | ||||
| @@ -396,23 +402,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js | ||||
| packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/theme.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/types.js | ||||
| packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js | ||||
| packages/app-mobile/components/NoteEditor/EditLinkDialog.js | ||||
| packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js | ||||
| @@ -425,7 +415,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js | ||||
| packages/app-mobile/components/NoteEditor/NoteEditor.test.js | ||||
| packages/app-mobile/components/NoteEditor/NoteEditor.js | ||||
| packages/app-mobile/components/NoteEditor/SearchPanel.js | ||||
| packages/app-mobile/components/NoteEditor/SelectionFormatting.js | ||||
| packages/app-mobile/components/NoteEditor/types.js | ||||
| packages/app-mobile/components/NoteList.js | ||||
| packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js | ||||
| @@ -475,6 +464,38 @@ packages/app-mobile/utils/fs-driver-rn.js | ||||
| packages/app-mobile/utils/setupNotifications.js | ||||
| packages/app-mobile/utils/shareHandler.js | ||||
| packages/app-mobile/utils/types.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js | ||||
| packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js | ||||
| packages/editor/CodeMirror/CodeMirrorControl.test.js | ||||
| packages/editor/CodeMirror/CodeMirrorControl.js | ||||
| packages/editor/CodeMirror/PluginLoader.js | ||||
| packages/editor/CodeMirror/configFromSettings.js | ||||
| packages/editor/CodeMirror/createEditor.test.js | ||||
| packages/editor/CodeMirror/createEditor.js | ||||
| packages/editor/CodeMirror/editorCommands/editorCommands.js | ||||
| packages/editor/CodeMirror/editorCommands/supportsCommand.js | ||||
| packages/editor/CodeMirror/editorCommands/swapLine.js | ||||
| packages/editor/CodeMirror/getScrollFraction.js | ||||
| packages/editor/CodeMirror/markdown/computeSelectionFormatting.js | ||||
| packages/editor/CodeMirror/markdown/decoratorExtension.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownCommands.js | ||||
| packages/editor/CodeMirror/markdown/markdownMathParser.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownMathParser.js | ||||
| packages/editor/CodeMirror/markdown/markdownReformatter.test.js | ||||
| packages/editor/CodeMirror/markdown/markdownReformatter.js | ||||
| packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js | ||||
| packages/editor/CodeMirror/testUtil/createEditorSettings.js | ||||
| packages/editor/CodeMirror/testUtil/createTestEditor.js | ||||
| packages/editor/CodeMirror/testUtil/forceFullParse.js | ||||
| packages/editor/CodeMirror/testUtil/loadLanguages.js | ||||
| packages/editor/CodeMirror/theme.js | ||||
| packages/editor/SelectionFormatting.js | ||||
| packages/editor/events.js | ||||
| packages/editor/types.js | ||||
| packages/fork-htmlparser2/src/CollectingHandler.js | ||||
| packages/fork-htmlparser2/src/FeedHandler.spec.js | ||||
| packages/fork-htmlparser2/src/FeedHandler.js | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| 				"exceptions": [ | ||||
| 					"@joplin/lib", | ||||
| 					"@joplin/renderer", | ||||
| 					"@joplin/editor", | ||||
| 					"@joplin/pdf-viewer", | ||||
| 					"@joplin/fork-htmlparser2", | ||||
| 					"@joplin/fork-sax", | ||||
|   | ||||
| @@ -192,7 +192,7 @@ class Application extends BaseApplication { | ||||
| 		// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly | ||||
| 		// https://github.com/laurent22/joplin/issues/155 | ||||
|  | ||||
| 		const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`; | ||||
| 		const css = `.CodeMirror *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`; | ||||
| 		const styleTag = document.createElement('style'); | ||||
| 		styleTag.type = 'text/css'; | ||||
| 		styleTag.appendChild(document.createTextNode(css)); | ||||
|   | ||||
| @@ -79,6 +79,7 @@ interface Props { | ||||
| 	startupPluginsLoaded: boolean; | ||||
| 	shareInvitations: ShareInvitation[]; | ||||
| 	isSafeMode: boolean; | ||||
| 	enableBetaMarkdownEditor: boolean; | ||||
| 	needApiAuth: boolean; | ||||
| 	processingShareInvitationResponse: boolean; | ||||
| 	isResettingLayout: boolean; | ||||
| @@ -737,7 +738,11 @@ class MainScreenComponent extends React.Component<Props, State> { | ||||
| 			}, | ||||
|  | ||||
| 			editor: () => { | ||||
| 				const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE'; | ||||
| 				let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE'; | ||||
|  | ||||
| 				if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) { | ||||
| 					bodyEditor = 'CodeMirror6'; | ||||
| 				} | ||||
| 				return <NoteEditor key={key} bodyEditor={bodyEditor} />; | ||||
| 			}, | ||||
| 		}; | ||||
| @@ -909,6 +914,7 @@ const mapStateToProps = (state: AppState) => { | ||||
| 		shareInvitations: state.shareService.shareInvitations, | ||||
| 		processingShareInvitationResponse: state.shareService.processingShareInvitationResponse, | ||||
| 		isSafeMode: state.settings.isSafeMode, | ||||
| 		enableBetaMarkdownEditor: state.settings['editor.beta'], | ||||
| 		needApiAuth: state.needApiAuth, | ||||
| 		showInstallTemplatesPlugin: state.hasLegacyTemplates && !state.pluginService.plugins['joplin.plugin.templates'], | ||||
| 		isResettingLayout: state.isResettingLayout, | ||||
|   | ||||
| @@ -1,60 +0,0 @@ | ||||
| import { NoteBodyEditorProps } from '../../../utils/types'; | ||||
| const { buildStyle } = require('@joplin/lib/theme'); | ||||
|  | ||||
| export default function styles(props: NoteBodyEditorProps) { | ||||
| 	return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: any) => { | ||||
| 		return { | ||||
| 			root: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'column', | ||||
| 				...props.style, | ||||
| 			}, | ||||
| 			rowToolbar: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'row', | ||||
| 			}, | ||||
| 			rowEditorViewer: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flexDirection: 'row', | ||||
| 				flex: 1, | ||||
| 				paddingTop: 10, | ||||
| 			}, | ||||
| 			cellEditor: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flex: 1, | ||||
| 			}, | ||||
| 			cellViewer: { | ||||
| 				position: 'relative', | ||||
| 				display: 'flex', | ||||
| 				flex: 1, | ||||
| 				borderLeftWidth: 1, | ||||
| 				borderLeftColor: theme.dividerColor, | ||||
| 				borderLeftStyle: 'solid', | ||||
| 			}, | ||||
| 			viewer: { | ||||
| 				display: 'flex', | ||||
| 				overflow: 'hidden', | ||||
| 				verticalAlign: 'top', | ||||
| 				boxSizing: 'border-box', | ||||
| 				width: '100%', | ||||
| 			}, | ||||
| 			editor: { | ||||
| 				display: 'flex', | ||||
| 				width: 'auto', | ||||
| 				height: 'auto', | ||||
| 				flex: 1, | ||||
| 				overflowY: 'hidden', | ||||
| 				paddingTop: 0, | ||||
| 				lineHeight: `${Math.round(17 * props.fontSize / 12)}px`, | ||||
| 				fontSize: `${props.fontSize}px`, | ||||
| 				color: theme.color, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 				codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js | ||||
| 			}, | ||||
| 		}; | ||||
| 	}); | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
|  | ||||
| const setupVim = (CodeMirror: CodeMirrorControl) => { | ||||
| 	CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown); | ||||
| 	CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true }); | ||||
| 	CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp); | ||||
| 	CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true }); | ||||
| 	CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement); | ||||
| 	CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true }); | ||||
| }; | ||||
|  | ||||
| export default setupVim; | ||||
| @@ -0,0 +1,175 @@ | ||||
|  | ||||
| import { ContextMenuEvent, ContextMenuParams } from 'electron'; | ||||
| import { useEffect, RefObject } from 'react'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; | ||||
| import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; | ||||
| import CommandService from '@joplin/lib/services/CommandService'; | ||||
| import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates'; | ||||
| 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'; | ||||
|  | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
| const menuUtils = new MenuUtils(CommandService.instance()); | ||||
|  | ||||
|  | ||||
| interface ContextMenuProps { | ||||
| 	plugins: PluginStates; | ||||
| 	editorCutText: ()=> void; | ||||
| 	editorCopyText: ()=> void; | ||||
| 	editorPaste: ()=> void; | ||||
| 	editorRef: RefObject<CodeMirrorControl>; | ||||
| 	editorClassName: string; | ||||
| } | ||||
|  | ||||
| const useContextMenu = (props: ContextMenuProps) => { | ||||
| 	const editorRef = props.editorRef; | ||||
|  | ||||
| 	// The below code adds support for spellchecking when it is enabled | ||||
| 	// It might be buggy, refer to the below issue | ||||
| 	// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703 | ||||
| 	useEffect(() => { | ||||
| 		const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => { | ||||
| 			for (; elem.parentElement; elem = elem.parentElement) { | ||||
| 				if (elem.classList.contains(props.editorClassName)) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return false; | ||||
| 		}; | ||||
|  | ||||
| 		let lastInCodeMirrorContextMenuTimestamp = 0; | ||||
|  | ||||
| 		// The browser's contextmenu event provides additional information about the | ||||
| 		// target of the event, not provided by the Electron context-menu event. | ||||
| 		const onBrowserContextMenu = (event: Event) => { | ||||
| 			if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) { | ||||
| 				lastInCodeMirrorContextMenuTimestamp = Date.now(); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		function pointerInsideEditor(params: ContextMenuParams) { | ||||
| 			const x = params.x, y = params.y, isEditable = params.isEditable; | ||||
| 			const elements = document.getElementsByClassName(props.editorClassName); | ||||
|  | ||||
| 			// Note: We can't check inputFieldType here. When spellcheck is enabled, | ||||
| 			// params.inputFieldType is "none". When spellcheck is disabled, | ||||
| 			// params.inputFieldType is "plainText". Thus, such a check would be inconsistent. | ||||
| 			if (!elements.length || !isEditable) return false; | ||||
|  | ||||
| 			const maximumMsSinceBrowserEvent = 100; | ||||
| 			if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) { | ||||
| 				return false; | ||||
| 			} | ||||
|  | ||||
| 			const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect()); | ||||
| 			return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y; | ||||
| 		} | ||||
|  | ||||
| 		async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) { | ||||
| 			if (!pointerInsideEditor(params)) return; | ||||
|  | ||||
| 			// Don't show the default menu. | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			const menu = new Menu(); | ||||
|  | ||||
| 			const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ; | ||||
|  | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Cut'), | ||||
| 					enabled: hasSelectedText, | ||||
| 					click: async () => { | ||||
| 						props.editorCutText(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
|  | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Copy'), | ||||
| 					enabled: hasSelectedText, | ||||
| 					click: async () => { | ||||
| 						props.editorCopyText(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
|  | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Paste'), | ||||
| 					enabled: true, | ||||
| 					click: async () => { | ||||
| 						props.editorPaste(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
|  | ||||
| 			const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions); | ||||
|  | ||||
| 			for (const item of spellCheckerMenuItems) { | ||||
| 				menu.append(new MenuItem(item)); | ||||
| 			} | ||||
|  | ||||
| 			// CodeMirror 5 only: | ||||
| 			// Typically CodeMirror handles all interactions itself (highlighting etc.) | ||||
| 			// But in the case of clicking a mispelled word, we need electron to handle the click | ||||
| 			// The result is that CodeMirror doesn't know what's been selected and doesn't | ||||
| 			// move the cursor into the correct location. | ||||
| 			// and when the user selects a new spelling it will be inserted in the wrong location | ||||
| 			// So in this situation, we use must manually align the internal codemirror selection | ||||
| 			// to the contextmenu selection | ||||
| 			if (editorRef.current && !editorRef.current.cm6 && spellCheckerMenuItems.length > 0) { | ||||
| 				(editorRef.current as any).alignSelection(params); | ||||
| 			} | ||||
|  | ||||
| 			let filterObject: EditContextMenuFilterObject = { | ||||
| 				items: [], | ||||
| 			}; | ||||
|  | ||||
| 			filterObject = await eventManager.filterEmit('editorContextMenu', filterObject); | ||||
|  | ||||
| 			for (const item of filterObject.items) { | ||||
| 				menu.append(new MenuItem({ | ||||
| 					label: item.label, | ||||
| 					click: async () => { | ||||
| 						const args = item.commandArgs || []; | ||||
| 						void CommandService.instance().execute(item.commandName, ...args); | ||||
| 					}, | ||||
| 					type: item.type, | ||||
| 				})); | ||||
| 			} | ||||
|  | ||||
| 			// eslint-disable-next-line github/array-foreach -- Old code before rule was applied | ||||
| 			menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => { | ||||
| 				menu.append(new MenuItem(item)); | ||||
| 			}); | ||||
|  | ||||
| 			menu.popup(); | ||||
| 		} | ||||
|  | ||||
| 		// Prepend the event listener so that it gets called before | ||||
| 		// the listener that shows the default menu. | ||||
| 		bridge().window().webContents.prependListener('context-menu', onContextMenu); | ||||
|  | ||||
| 		window.addEventListener('contextmenu', onBrowserContextMenu); | ||||
|  | ||||
| 		return () => { | ||||
| 			bridge().window().webContents.off('context-menu', onContextMenu); | ||||
| 			window.removeEventListener('contextmenu', onBrowserContextMenu); | ||||
| 		}; | ||||
| 	}, [ | ||||
| 		props.plugins, props.editorClassName, editorRef, | ||||
| 		props.editorCutText, props.editorCopyText, props.editorPaste, | ||||
| 	]); | ||||
| }; | ||||
|  | ||||
| export default useContextMenu; | ||||
| @@ -4,6 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService'; | ||||
| import { EditorCommand } from '../../../utils/types'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import setupVim from './setupVim'; | ||||
|  | ||||
| export default function useKeymap(CodeMirror: any) { | ||||
|  | ||||
| @@ -17,14 +18,6 @@ export default function useKeymap(CodeMirror: any) { | ||||
| 		CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent'; | ||||
| 	} | ||||
|  | ||||
| 	function setupVim() { | ||||
| 		CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown); | ||||
| 		CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true }); | ||||
| 		CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp); | ||||
| 		CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true }); | ||||
| 		CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement); | ||||
| 		CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true }); | ||||
| 	} | ||||
| 	function isEditorCommand(command: string) { | ||||
| 		return command.startsWith('editor.'); | ||||
| 	} | ||||
| @@ -184,7 +177,7 @@ export default function useKeymap(CodeMirror: any) { | ||||
| 		keymapService.on('keymapChange', registerKeymap); | ||||
|  | ||||
| 		setupEmacs(); | ||||
| 		setupVim(); | ||||
| 		setupVim(CodeMirror); | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied | ||||
| 	}, []); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,82 @@ | ||||
| import { Theme } from '@joplin/lib/themes/type'; | ||||
| import { NoteBodyEditorProps } from '../../../utils/types'; | ||||
| import { buildStyle } from '@joplin/lib/theme'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
|  | ||||
|  | ||||
| const useStyles = (props: NoteBodyEditorProps) => { | ||||
| 	return useMemo(() => { | ||||
| 		return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: Theme) => { | ||||
| 			return { | ||||
| 				root: { | ||||
| 					position: 'relative', | ||||
| 					display: 'flex', | ||||
| 					flexDirection: 'column', | ||||
| 					minHeight: 0, | ||||
| 					...props.style, | ||||
| 				}, | ||||
| 				rowToolbar: { | ||||
| 					position: 'relative', | ||||
| 					display: 'flex', | ||||
| 					flexDirection: 'row', | ||||
| 				}, | ||||
| 				rowEditorViewer: { | ||||
| 					position: 'relative', | ||||
| 					display: 'flex', | ||||
| 					flexDirection: 'row', | ||||
| 					flex: 1, | ||||
| 					paddingTop: 10, | ||||
|  | ||||
| 					// Allow the editor container to shrink (allowing the editor to scroll) | ||||
| 					minHeight: 0, | ||||
| 				}, | ||||
| 				cellEditor: { | ||||
| 					position: 'relative', | ||||
| 					display: 'flex', | ||||
| 					flex: 1, | ||||
| 				}, | ||||
| 				cellViewer: { | ||||
| 					position: 'relative', | ||||
| 					display: 'flex', | ||||
| 					flex: 1, | ||||
| 					borderLeftWidth: 1, | ||||
| 					borderLeftColor: theme.dividerColor, | ||||
| 					borderLeftStyle: 'solid', | ||||
| 				}, | ||||
| 				viewer: { | ||||
| 					display: 'flex', | ||||
| 					overflow: 'hidden', | ||||
| 					verticalAlign: 'top', | ||||
| 					boxSizing: 'border-box', | ||||
| 					width: '100%', | ||||
| 				}, | ||||
| 				editor: { | ||||
| 					display: 'flex', | ||||
| 					width: 'auto', | ||||
| 					height: 'auto', | ||||
| 					flex: 1, | ||||
| 					overflowY: 'hidden', | ||||
| 					paddingTop: 0, | ||||
| 					lineHeight: `${Math.round(17 * props.fontSize / 12)}px`, | ||||
| 					fontSize: `${props.fontSize}px`, | ||||
| 					color: theme.color, | ||||
| 					backgroundColor: theme.backgroundColor, | ||||
|  | ||||
| 					// CM5 only | ||||
| 					codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js | ||||
| 				}, | ||||
|  | ||||
| 				// CM6 only | ||||
| 				globalTheme: { | ||||
| 					...theme, | ||||
| 					fontFamily: 'inherit', | ||||
| 					fontSize: props.fontSize, | ||||
| 					fontSizeUnits: 'px', | ||||
| 					isDesktop: true, | ||||
| 				}, | ||||
| 			}; | ||||
| 		}); | ||||
| 	}, [props.style, props.themeId, props.fontSize]); | ||||
| }; | ||||
| export default useStyles; | ||||
| @@ -0,0 +1,46 @@ | ||||
| import type CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation'; | ||||
| import shared from '@joplin/lib/components/shared/note-screen-shared'; | ||||
| import { useCallback, RefObject } from 'react'; | ||||
|  | ||||
| interface Props { | ||||
| 	onMessage(event: any): void; | ||||
| 	getLineScrollPercent(): number; | ||||
| 	setEditorPercentScroll(fraction: number): void; | ||||
| 	editorRef: RefObject<CodeMirror5Emulation>; | ||||
| 	content: string; | ||||
| } | ||||
|  | ||||
| const useWebviewIpcMessage = (props: Props) => { | ||||
| 	const editorRef = props.editorRef; | ||||
|  | ||||
| 	return useCallback((event: any) => { | ||||
| 		const msg = event.channel ? event.channel : ''; | ||||
| 		const args = event.args; | ||||
| 		const arg0 = args && args.length >= 1 ? args[0] : null; | ||||
|  | ||||
| 		if (msg.indexOf('checkboxclick:') === 0) { | ||||
| 			const { line, from, to } = shared.toggleCheckboxRange(msg, props.content); | ||||
| 			if (editorRef.current) { | ||||
| 				// To cancel CodeMirror's layout drift, the scroll position | ||||
| 				// is recorded before updated, and then it is restored. | ||||
| 				// Ref. https://github.com/laurent22/joplin/issues/5890 | ||||
| 				const percent = props.getLineScrollPercent(); | ||||
| 				editorRef.current.replaceRange(line, from, to); | ||||
| 				props.setEditorPercentScroll(percent); | ||||
| 			} | ||||
| 		} else if (msg === 'percentScroll') { | ||||
| 			const percent = arg0; | ||||
| 			props.setEditorPercentScroll(percent); | ||||
| 		} else { | ||||
| 			props.onMessage(event); | ||||
| 		} | ||||
| 	}, [ | ||||
| 		props.onMessage, | ||||
| 		props.content, | ||||
| 		editorRef, | ||||
| 		props.getLineScrollPercent, | ||||
| 		props.setEditorPercentScroll, | ||||
| 	]); | ||||
| }; | ||||
|  | ||||
| export default useWebviewIpcMessage; | ||||
| @@ -1,55 +1,45 @@ | ||||
| import * as React from 'react'; | ||||
| import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'; | ||||
| import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; | ||||
| 
 | ||||
| // eslint-disable-next-line no-unused-vars
 | ||||
| import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; | ||||
| import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling'; | ||||
| import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; | ||||
| import { CommandValue } from '../../utils/types'; | ||||
| import { usePrevious, cursorPositionToTextOffset } from './utils'; | ||||
| import useScrollHandler from './utils/useScrollHandler'; | ||||
| import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types'; | ||||
| import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; | ||||
| import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types'; | ||||
| import { CommandValue } from '../../../utils/types'; | ||||
| import { usePrevious, cursorPositionToTextOffset } from '../utils'; | ||||
| import useScrollHandler from '../utils/useScrollHandler'; | ||||
| import useElementSize from '@joplin/lib/hooks/useElementSize'; | ||||
| import Toolbar from './Toolbar'; | ||||
| import styles_ from './styles'; | ||||
| import { RenderedBody, defaultRenderedBody } from './utils/types'; | ||||
| import NoteTextViewer from '../../../NoteTextViewer'; | ||||
| import Toolbar from '../Toolbar'; | ||||
| import { RenderedBody, defaultRenderedBody } from '../utils/types'; | ||||
| import NoteTextViewer from '../../../../NoteTextViewer'; | ||||
| import Editor from './Editor'; | ||||
| import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration'; | ||||
| import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import bridge from '../../../../services/bridge'; | ||||
| import bridge from '../../../../../services/bridge'; | ||||
| import markdownUtils from '@joplin/lib/markdownUtils'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; | ||||
| import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; | ||||
| import CommandService from '@joplin/lib/services/CommandService'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import { ThemeAppearance } from '@joplin/lib/themes/type'; | ||||
| import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; | ||||
| import dialogs from '../../../dialogs'; | ||||
| import convertToScreenCoordinates from '../../../utils/convertToScreenCoordinates'; | ||||
| import dialogs from '../../../../dialogs'; | ||||
| import { MarkupToHtml } from '@joplin/renderer'; | ||||
| const { clipboard } = require('electron'); | ||||
| const debounce = require('debounce'); | ||||
| import shared from '@joplin/lib/components/shared/note-screen-shared'; | ||||
| const Menu = bridge().Menu; | ||||
| const MenuItem = bridge().MenuItem; | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import ErrorBoundary from '../../../ErrorBoundary'; | ||||
| import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml'; | ||||
| import eventManager from '@joplin/lib/eventManager'; | ||||
| import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace'; | ||||
| import type { ContextMenuEvent, ContextMenuParams } from 'electron'; | ||||
| 
 | ||||
| const menuUtils = new MenuUtils(CommandService.instance()); | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import ErrorBoundary from '../../../../ErrorBoundary'; | ||||
| import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml'; | ||||
| import useStyles from '../utils/useStyles'; | ||||
| import useContextMenu from '../utils/useContextMenu'; | ||||
| import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; | ||||
| 
 | ||||
| function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { | ||||
| 	return { ...override }; | ||||
| } | ||||
| 
 | ||||
| function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
| 	const styles = styles_(props); | ||||
| function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) { | ||||
| 	const styles = useStyles(props); | ||||
| 
 | ||||
| 	const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
 | ||||
| 	const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null); | ||||
| @@ -599,29 +589,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
| 		setWebviewReady(true); | ||||
| 	}, []); | ||||
| 
 | ||||
| 	const webview_ipcMessage = useCallback((event: any) => { | ||||
| 		const msg = event.channel ? event.channel : ''; | ||||
| 		const args = event.args; | ||||
| 		const arg0 = args && args.length >= 1 ? args[0] : null; | ||||
| 
 | ||||
| 		if (msg.indexOf('checkboxclick:') === 0) { | ||||
| 			const { line, from, to } = shared.toggleCheckboxRange(msg, props.content); | ||||
| 			if (editorRef.current) { | ||||
| 				// To cancel CodeMirror's layout drift, the scroll position
 | ||||
| 				// is recorded before updated, and then it is restored.
 | ||||
| 				// Ref. https://github.com/laurent22/joplin/issues/5890
 | ||||
| 				const percent = getLineScrollPercent(); | ||||
| 				editorRef.current.replaceRange(line, from, to); | ||||
| 				setEditorPercentScroll(percent); | ||||
| 			} | ||||
| 		} else if (msg === 'percentScroll') { | ||||
| 			const percent = arg0; | ||||
| 			setEditorPercentScroll(percent); | ||||
| 		} else { | ||||
| 			props.onMessage(event); | ||||
| 		} | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
 | ||||
| 	}, [props.onMessage, props.content, setEditorPercentScroll]); | ||||
| 	const webview_ipcMessage = useWebviewIpcMessage({ | ||||
| 		editorRef, | ||||
| 		setEditorPercentScroll, | ||||
| 		getLineScrollPercent, | ||||
| 		content: props.content, | ||||
| 		onMessage: props.onMessage, | ||||
| 	}); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		let cancelled = false; | ||||
| @@ -779,142 +753,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
| 		editorRef.current.refresh(); | ||||
| 	}, [rootSize, styles.editor, props.visiblePanes]); | ||||
| 
 | ||||
| 	// The below code adds support for spellchecking when it is enabled
 | ||||
| 	// It might be buggy, refer to the below issue
 | ||||
| 	// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
 | ||||
| 	useEffect(() => { | ||||
| 		const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => { | ||||
| 			for (; elem.parentElement; elem = elem.parentElement) { | ||||
| 				if (elem.classList.contains('codeMirrorEditor')) { | ||||
| 					return true; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			return false; | ||||
| 		}; | ||||
| 
 | ||||
| 		let lastInCodeMirrorContextMenuTimestamp = 0; | ||||
| 
 | ||||
| 		// The browser's contextmenu event provides additional information about the
 | ||||
| 		// target of the event, not provided by the Electron context-menu event.
 | ||||
| 		const onBrowserContextMenu = (event: Event) => { | ||||
| 			if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) { | ||||
| 				lastInCodeMirrorContextMenuTimestamp = Date.now(); | ||||
| 			} | ||||
| 		}; | ||||
| 
 | ||||
| 		function pointerInsideEditor(params: ContextMenuParams) { | ||||
| 			const x = params.x, y = params.y, isEditable = params.isEditable; | ||||
| 			const elements = document.getElementsByClassName('codeMirrorEditor'); | ||||
| 
 | ||||
| 			// Note: We can't check inputFieldType here. When spellcheck is enabled,
 | ||||
| 			// params.inputFieldType is "none". When spellcheck is disabled,
 | ||||
| 			// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
 | ||||
| 			if (!elements.length || !isEditable) return false; | ||||
| 
 | ||||
| 			const maximumMsSinceBrowserEvent = 100; | ||||
| 			if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) { | ||||
| 				return false; | ||||
| 			} | ||||
| 
 | ||||
| 			const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect()); | ||||
| 			return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y; | ||||
| 		} | ||||
| 
 | ||||
| 		async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) { | ||||
| 			if (!pointerInsideEditor(params)) return; | ||||
| 
 | ||||
| 			// Don't show the default menu.
 | ||||
| 			event.preventDefault(); | ||||
| 
 | ||||
| 			const menu = new Menu(); | ||||
| 
 | ||||
| 			const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ; | ||||
| 
 | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Cut'), | ||||
| 					enabled: hasSelectedText, | ||||
| 					click: async () => { | ||||
| 						editorCutText(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
| 
 | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Copy'), | ||||
| 					enabled: hasSelectedText, | ||||
| 					click: async () => { | ||||
| 						editorCopyText(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
| 
 | ||||
| 			menu.append( | ||||
| 				new MenuItem({ | ||||
| 					label: _('Paste'), | ||||
| 					enabled: true, | ||||
| 					click: async () => { | ||||
| 						editorPaste(); | ||||
| 					}, | ||||
| 				}), | ||||
| 			); | ||||
| 
 | ||||
| 			const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions); | ||||
| 
 | ||||
| 			for (const item of spellCheckerMenuItems) { | ||||
| 				menu.append(new MenuItem(item)); | ||||
| 			} | ||||
| 
 | ||||
| 			// Typically CodeMirror handles all interactions itself (highlighting etc.)
 | ||||
| 			// But in the case of clicking a mispelled word, we need electron to handle the click
 | ||||
| 			// The result is that CodeMirror doesn't know what's been selected and doesn't
 | ||||
| 			// move the cursor into the correct location.
 | ||||
| 			// and when the user selects a new spelling it will be inserted in the wrong location
 | ||||
| 			// So in this situation, we use must manually align the internal codemirror selection
 | ||||
| 			// to the contextmenu selection
 | ||||
| 			if (editorRef.current && spellCheckerMenuItems.length > 0) { | ||||
| 				editorRef.current.alignSelection(params); | ||||
| 			} | ||||
| 
 | ||||
| 			let filterObject: EditContextMenuFilterObject = { | ||||
| 				items: [], | ||||
| 			}; | ||||
| 
 | ||||
| 			filterObject = await eventManager.filterEmit('editorContextMenu', filterObject); | ||||
| 
 | ||||
| 			for (const item of filterObject.items) { | ||||
| 				menu.append(new MenuItem({ | ||||
| 					label: item.label, | ||||
| 					click: async () => { | ||||
| 						const args = item.commandArgs || []; | ||||
| 						void CommandService.instance().execute(item.commandName, ...args); | ||||
| 					}, | ||||
| 					type: item.type, | ||||
| 				})); | ||||
| 			} | ||||
| 
 | ||||
| 			// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
 | ||||
| 			menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => { | ||||
| 				menu.append(new MenuItem(item)); | ||||
| 			}); | ||||
| 
 | ||||
| 			menu.popup(); | ||||
| 		} | ||||
| 
 | ||||
| 		// Prepend the event listener so that it gets called before
 | ||||
| 		// the listener that shows the default menu.
 | ||||
| 		bridge().window().webContents.prependListener('context-menu', onContextMenu); | ||||
| 
 | ||||
| 		window.addEventListener('contextmenu', onBrowserContextMenu); | ||||
| 
 | ||||
| 		return () => { | ||||
| 			bridge().window().webContents.off('context-menu', onContextMenu); | ||||
| 			window.removeEventListener('contextmenu', onBrowserContextMenu); | ||||
| 		}; | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
 | ||||
| 	}, [props.plugins]); | ||||
| 	useContextMenu({ | ||||
| 		plugins: props.plugins, | ||||
| 		editorCutText, editorCopyText, editorPaste, | ||||
| 		editorRef, | ||||
| 		editorClassName: 'codeMirrorEditor', | ||||
| 	}); | ||||
| 
 | ||||
| 	function renderEditor() { | ||||
| 
 | ||||
| @@ -12,15 +12,15 @@ import 'codemirror/addon/scroll/annotatescrollbar'; | ||||
| import 'codemirror/addon/search/matchesonscrollbar'; | ||||
| import 'codemirror/addon/search/searchcursor'; | ||||
| 
 | ||||
| import useListIdent from './utils/useListIdent'; | ||||
| import useScrollUtils from './utils/useScrollUtils'; | ||||
| import useCursorUtils from './utils/useCursorUtils'; | ||||
| import useLineSorting from './utils/useLineSorting'; | ||||
| import useEditorSearch from './utils/useEditorSearch'; | ||||
| import useJoplinMode from './utils/useJoplinMode'; | ||||
| import useKeymap from './utils/useKeymap'; | ||||
| import useExternalPlugins from './utils/useExternalPlugins'; | ||||
| import useJoplinCommands from './utils/useJoplinCommands'; | ||||
| import useListIdent from '../utils/useListIdent'; | ||||
| import useScrollUtils from '../utils/useScrollUtils'; | ||||
| import useCursorUtils from '../utils/useCursorUtils'; | ||||
| import useLineSorting from '../utils/useLineSorting'; | ||||
| import useEditorSearch from '../utils/useEditorSearch'; | ||||
| import useJoplinMode from '../utils/useJoplinMode'; | ||||
| import useKeymap from '../utils/useKeymap'; | ||||
| import useExternalPlugins from '../utils/useExternalPlugins'; | ||||
| import useJoplinCommands from '../utils/useJoplinCommands'; | ||||
| 
 | ||||
| import 'codemirror/keymap/emacs'; | ||||
| import 'codemirror/keymap/vim'; | ||||
| @@ -0,0 +1,428 @@ | ||||
| import * as React from 'react'; | ||||
| import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; | ||||
|  | ||||
| import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types'; | ||||
| import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; | ||||
| import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types'; | ||||
| import NoteTextViewer from '../../../../NoteTextViewer'; | ||||
| import Editor from './Editor'; | ||||
| import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import bridge from '../../../../../services/bridge'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { MarkupToHtml } from '@joplin/renderer'; | ||||
| const { clipboard } = require('electron'); | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import ErrorBoundary from '../../../../ErrorBoundary'; | ||||
| import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml'; | ||||
| import { EditorKeymap, EditorLanguageType, EditorSettings } from '@joplin/editor/types'; | ||||
| import useStyles from '../utils/useStyles'; | ||||
| import { EditorEvent, EditorEventType } from '@joplin/editor/events'; | ||||
| import useScrollHandler from '../utils/useScrollHandler'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import useEditorCommands from './useEditorCommands'; | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
| import useContextMenu from '../utils/useContextMenu'; | ||||
| import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; | ||||
| import Toolbar from '../Toolbar'; | ||||
|  | ||||
| const logger = Logger.create('CodeMirror6'); | ||||
| const logDebug = (message: string) => logger.debug(message); | ||||
|  | ||||
| interface RenderedBody { | ||||
| 	html: string; | ||||
| 	pluginAssets: any[]; | ||||
| } | ||||
|  | ||||
| function defaultRenderedBody(): RenderedBody { | ||||
| 	return { | ||||
| 		html: '', | ||||
| 		pluginAssets: [], | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { | ||||
| 	return { ...override }; | ||||
| } | ||||
|  | ||||
| const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => { | ||||
| 	const styles = useStyles(props); | ||||
|  | ||||
| 	const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content | ||||
| 	const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null); | ||||
|  | ||||
| 	const [webviewReady, setWebviewReady] = useState(false); | ||||
|  | ||||
| 	const editorRef = useRef<CodeMirrorControl>(null); | ||||
| 	const rootRef = useRef(null); | ||||
| 	const webviewRef = useRef(null); | ||||
|  | ||||
| 	type OnChangeCallback = (event: OnChangeEvent)=> void; | ||||
| 	const props_onChangeRef = useRef<OnChangeCallback>(null); | ||||
| 	props_onChangeRef.current = props.onChange; | ||||
|  | ||||
| 	const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 }); | ||||
|  | ||||
| 	const { | ||||
| 		resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent, | ||||
| 	} = useScrollHandler(editorRef, webviewRef, props.onScroll); | ||||
|  | ||||
| 	usePluginServiceRegistration(ref); | ||||
|  | ||||
| 	const codeMirror_change = useCallback((newBody: string) => { | ||||
| 		if (newBody !== props.content) { | ||||
| 			props_onChangeRef.current({ changeId: null, content: newBody }); | ||||
| 		} | ||||
| 	}, [props.content]); | ||||
|  | ||||
| 	const onEditorPaste = useCallback(async (event: any = null) => { | ||||
| 		const resourceMds = await getResourcesFromPasteEvent(event); | ||||
| 		if (!resourceMds.length) return; | ||||
| 		if (editorRef.current) { | ||||
| 			editorRef.current.insertText(resourceMds.join('\n')); | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	const editorCutText = useCallback(() => { | ||||
| 		if (editorRef.current) { | ||||
| 			const selections = editorRef.current.getSelections(); | ||||
| 			if (selections.length > 0 && selections[0]) { | ||||
| 				clipboard.writeText(selections[0]); | ||||
| 				// Easy way to wipe out just the first selection | ||||
| 				selections[0] = ''; | ||||
| 				editorRef.current.replaceSelections(selections); | ||||
| 			} else { | ||||
| 				const cursor = editorRef.current.getCursor(); | ||||
| 				const line = editorRef.current.getLine(cursor.line); | ||||
| 				clipboard.writeText(`${line}\n`); | ||||
| 				const startLine = editorRef.current.getCursor('head'); | ||||
| 				startLine.ch = 0; | ||||
| 				const endLine = { | ||||
| 					line: startLine.line + 1, | ||||
| 					ch: 0, | ||||
| 				}; | ||||
| 				editorRef.current.replaceRange('', startLine, endLine); | ||||
| 			} | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	const editorCopyText = useCallback(() => { | ||||
| 		if (editorRef.current) { | ||||
| 			const selections = editorRef.current.getSelections(); | ||||
|  | ||||
| 			// Handle the case when there is a selection - copy the selection to the clipboard | ||||
| 			// When there is no selection, the selection array contains an empty string. | ||||
| 			if (selections.length > 0 && selections[0]) { | ||||
| 				clipboard.writeText(selections[0]); | ||||
| 			} else { | ||||
| 				// This is the case when there is no selection - copy the current line to the clipboard | ||||
| 				const cursor = editorRef.current.getCursor(); | ||||
| 				const line = editorRef.current.getLine(cursor.line); | ||||
| 				clipboard.writeText(line); | ||||
| 			} | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	const editorPasteText = useCallback(async () => { | ||||
| 		if (editorRef.current) { | ||||
| 			const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true }); | ||||
| 			editorRef.current.insertText(modifiedMd); | ||||
| 		} | ||||
| 	}, []); | ||||
|  | ||||
| 	const editorPaste = useCallback(() => { | ||||
| 		const clipboardText = clipboard.readText(); | ||||
|  | ||||
| 		if (clipboardText) { | ||||
| 			void editorPasteText(); | ||||
| 		} else { | ||||
| 			// To handle pasting images | ||||
| 			void onEditorPaste(); | ||||
| 		} | ||||
| 	}, [editorPasteText, onEditorPaste]); | ||||
|  | ||||
| 	const commands = useEditorCommands({ | ||||
| 		webviewRef, | ||||
| 		editorRef, | ||||
| 		selectionRange, | ||||
|  | ||||
| 		editorCopyText, editorCutText, editorPaste, | ||||
| 		editorContent: props.content, | ||||
| 		visiblePanes: props.visiblePanes, | ||||
| 	}); | ||||
|  | ||||
| 	useImperativeHandle(ref, () => { | ||||
| 		return { | ||||
| 			content: () => props.content, | ||||
| 			resetScroll: () => { | ||||
| 				resetScroll(); | ||||
| 			}, | ||||
| 			scrollTo: (options: ScrollOptions) => { | ||||
| 				if (options.type === ScrollOptionTypes.Hash) { | ||||
| 					if (!webviewRef.current) return; | ||||
| 					webviewRef.current.send('scrollToHash', options.value as string); | ||||
| 				} else if (options.type === ScrollOptionTypes.Percent) { | ||||
| 					const percent = options.value as number; | ||||
| 					setEditorPercentScroll(percent); | ||||
| 					setViewerPercentScroll(percent); | ||||
|  | ||||
| 				} else { | ||||
| 					throw new Error(`Unsupported scroll options: ${options.type}`); | ||||
| 				} | ||||
| 			}, | ||||
| 			supportsCommand: (name: string) => { | ||||
| 				return name in commands || editorRef.current.supportsCommand(name); | ||||
| 			}, | ||||
| 			execCommand: async (cmd: EditorCommand) => { | ||||
| 				if (!editorRef.current) return false; | ||||
|  | ||||
| 				logger.debug('execCommand', cmd); | ||||
|  | ||||
| 				let commandOutput = null; | ||||
| 				if (cmd.name in commands) { | ||||
| 					commandOutput = (commands as any)[cmd.name](cmd.value); | ||||
| 				} else if (editorRef.current.supportsCommand(cmd.name)) { | ||||
| 					commandOutput = editorRef.current.execCommand(cmd.name); | ||||
| 				} else if (editorRef.current.supportsJoplinCommand(cmd.name)) { | ||||
| 					commandOutput = editorRef.current.execJoplinCommand(cmd.name); | ||||
| 				} else { | ||||
| 					reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd); | ||||
| 				} | ||||
|  | ||||
| 				return commandOutput; | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]); | ||||
|  | ||||
| 	const webview_domReady = useCallback(() => { | ||||
| 		setWebviewReady(true); | ||||
| 	}, []); | ||||
|  | ||||
| 	const webview_ipcMessage = useWebviewIpcMessage({ | ||||
| 		editorRef, | ||||
| 		setEditorPercentScroll, | ||||
| 		getLineScrollPercent, | ||||
| 		content: props.content, | ||||
| 		onMessage: props.onMessage, | ||||
| 	}); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		let cancelled = false; | ||||
|  | ||||
| 		// When a new note is loaded (contentKey is different), we want the note to be displayed | ||||
| 		// right away. However once that's done, we put a small delay so that the view is not | ||||
| 		// being constantly updated while the user changes the note. | ||||
| 		const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500; | ||||
|  | ||||
| 		const timeoutId = shim.setTimeout(async () => { | ||||
| 			let bodyToRender = props.content; | ||||
|  | ||||
| 			if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) { | ||||
| 				// Fixes https://github.com/laurent22/joplin/issues/217 | ||||
| 				bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`; | ||||
| 			} | ||||
|  | ||||
| 			const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ | ||||
| 				resourceInfos: props.resourceInfos, | ||||
| 				contentMaxWidth: props.contentMaxWidth, | ||||
| 				mapsToLine: true, | ||||
| 				// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to. | ||||
| 				useCustomPdfViewer: props.useCustomPdfViewer, | ||||
| 				noteId: props.noteId, | ||||
| 				vendorDir: bridge().vendorDir(), | ||||
| 			})); | ||||
|  | ||||
| 			if (cancelled) return; | ||||
|  | ||||
| 			setRenderedBody(result); | ||||
|  | ||||
| 			// Since we set `renderedBodyContentKey` here, it means this effect is going to | ||||
| 			// be triggered again, but that's hard to avoid and the second call would be cheap | ||||
| 			// anyway since the renderered markdown is cached by MdToHtml. We could use a ref | ||||
| 			// to avoid this, but a second rendering might still happens anyway to render images, | ||||
| 			// resources, or for other reasons. So it's best to focus on making any second call | ||||
| 			// to this effect as cheap as possible with caching, etc. | ||||
| 			setRenderedBodyContentKey(props.contentKey); | ||||
| 		}, interval); | ||||
|  | ||||
| 		return () => { | ||||
| 			cancelled = true; | ||||
| 			shim.clearTimeout(timeoutId); | ||||
| 		}; | ||||
| 	}, [ | ||||
| 		props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, | ||||
| 		props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth, | ||||
| 		props.noteId, props.useCustomPdfViewer, | ||||
| 	]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!webviewReady) return; | ||||
|  | ||||
| 		let lineCount = 0; | ||||
| 		if (editorRef.current) { | ||||
| 			lineCount = editorRef.current.editor.state.doc.lines; | ||||
| 		} | ||||
|  | ||||
| 		const options: any = { | ||||
| 			pluginAssets: renderedBody.pluginAssets, | ||||
| 			downloadResources: Setting.value('sync.resourceDownloadMode'), | ||||
| 			markupLineCount: lineCount, | ||||
| 		}; | ||||
|  | ||||
| 		// It seems when there's an error immediately when the component is | ||||
| 		// mounted, webviewReady might be true, but webviewRef.current will be | ||||
| 		// undefined. Maybe due to the error boundary that unmount components. | ||||
| 		// Since we can't do much about it we just print an error. | ||||
| 		if (webviewRef.current) { | ||||
| 			// To keep consistency among CodeMirror's editing and scroll percents | ||||
| 			// of Editor and Viewer. | ||||
| 			const percent = getLineScrollPercent(); | ||||
| 			setEditorPercentScroll(percent); | ||||
| 			options.percent = percent; | ||||
| 			webviewRef.current.send('setHtml', renderedBody.html, options); | ||||
| 		} else { | ||||
| 			console.error('Trying to set HTML on an undefined webview ref'); | ||||
| 		} | ||||
| 	}, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]); | ||||
|  | ||||
| 	const cellEditorStyle = useMemo(() => { | ||||
| 		const output = { ...styles.cellEditor }; | ||||
| 		if (!props.visiblePanes.includes('editor')) { | ||||
| 			output.display = 'none'; // Seems to work fine since the refactoring | ||||
| 		} | ||||
|  | ||||
| 		return output; | ||||
| 	}, [styles.cellEditor, props.visiblePanes]); | ||||
|  | ||||
| 	const cellViewerStyle = useMemo(() => { | ||||
| 		const output = { ...styles.cellViewer }; | ||||
| 		if (!props.visiblePanes.includes('viewer')) { | ||||
| 			// Note: setting webview.display to "none" is currently not supported due | ||||
| 			// to this bug: https://github.com/electron/electron/issues/8277 | ||||
| 			// So instead setting the width 0. | ||||
| 			output.width = 1; | ||||
| 			output.maxWidth = 1; | ||||
| 		} else if (!props.visiblePanes.includes('editor')) { | ||||
| 			output.borderLeftStyle = 'none'; | ||||
| 		} | ||||
| 		return output; | ||||
| 	}, [styles.cellViewer, props.visiblePanes]); | ||||
|  | ||||
| 	const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0; | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editorRef.current) return; | ||||
|  | ||||
| 		// Anytime the user toggles the visible panes AND the editor is visible as a result | ||||
| 		// we should focus the editor | ||||
| 		// The intuition is that a panel toggle (with editor in view) is the equivalent of | ||||
| 		// an editor interaction so users should expect the editor to be focused | ||||
| 		if (editorPaneVisible) { | ||||
| 			editorRef.current.focus(); | ||||
| 		} | ||||
| 	}, [editorPaneVisible]); | ||||
|  | ||||
| 	useContextMenu({ | ||||
| 		plugins: props.plugins, | ||||
| 		editorCutText, editorCopyText, editorPaste, | ||||
| 		editorRef, | ||||
| 		editorClassName: 'cm-editor', | ||||
| 	}); | ||||
|  | ||||
| 	const onEditorEvent = useCallback((event: EditorEvent) => { | ||||
| 		if (event.kind === EditorEventType.Scroll) { | ||||
| 			editor_scroll(); | ||||
| 		} else if (event.kind === EditorEventType.Change) { | ||||
| 			codeMirror_change(event.value); | ||||
| 		} else if (event.kind === EditorEventType.SelectionRangeChange) { | ||||
| 			setSelectionRange({ from: event.from, to: event.to }); | ||||
| 		} | ||||
| 	}, [editor_scroll, codeMirror_change]); | ||||
|  | ||||
| 	const editorSettings = useMemo((): EditorSettings => { | ||||
| 		const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML; | ||||
|  | ||||
| 		let keyboardMode = EditorKeymap.Default; | ||||
| 		if (props.keyboardMode === 'vim') { | ||||
| 			keyboardMode = EditorKeymap.Vim; | ||||
| 		} else if (props.keyboardMode === 'emacs') { | ||||
| 			keyboardMode = EditorKeymap.Emacs; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown, | ||||
| 			readOnly: props.disabled || props.visiblePanes.indexOf('editor') < 0, | ||||
| 			katexEnabled: Setting.value('markdown.plugin.katex'), | ||||
| 			themeData: { | ||||
| 				...styles.globalTheme, | ||||
| 				monospaceFont: Setting.value('style.editor.monospaceFontFamily'), | ||||
| 			}, | ||||
| 			automatchBraces: Setting.value('editor.autoMatchingBraces'), | ||||
| 			useExternalSearch: false, | ||||
| 			ignoreModifiers: true, | ||||
| 			spellcheckEnabled: Setting.value('editor.spellcheckBeta'), | ||||
| 			keymap: keyboardMode, | ||||
| 			indentWithTabs: true, | ||||
| 		}; | ||||
| 	}, [ | ||||
| 		props.contentMarkupLanguage, props.disabled, props.visiblePanes, | ||||
| 		props.keyboardMode, styles.globalTheme, | ||||
| 	]); | ||||
|  | ||||
| 	// Update the editor's value | ||||
| 	useEffect(() => { | ||||
| 		if (editorRef.current?.updateBody(props.content)) { | ||||
| 			editorRef.current?.clearHistory(); | ||||
| 		} | ||||
| 	}, [props.content]); | ||||
|  | ||||
| 	const renderEditor = () => { | ||||
| 		return ( | ||||
| 			<div style={cellEditorStyle}> | ||||
| 				<Editor | ||||
| 					style={styles.editor} | ||||
| 					initialText={props.content} | ||||
| 					ref={editorRef} | ||||
| 					settings={editorSettings} | ||||
| 					pluginStates={props.plugins} | ||||
| 					onEvent={onEditorEvent} | ||||
| 					onLogMessage={logDebug} | ||||
| 				/> | ||||
| 			</div> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderViewer = () => { | ||||
| 		return ( | ||||
| 			<div style={cellViewerStyle}> | ||||
| 				<NoteTextViewer | ||||
| 					ref={webviewRef} | ||||
| 					themeId={props.themeId} | ||||
| 					viewerStyle={styles.viewer} | ||||
| 					onIpcMessage={webview_ipcMessage} | ||||
| 					onDomReady={webview_domReady} | ||||
| 					contentMaxWidth={props.contentMaxWidth} | ||||
| 				/> | ||||
| 			</div> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again."> | ||||
| 			<div style={styles.root} ref={rootRef}> | ||||
| 				<div style={styles.rowToolbar}> | ||||
| 					<Toolbar themeId={props.themeId}/> | ||||
| 					{props.noteToolbar} | ||||
| 				</div> | ||||
| 				<div style={styles.rowEditorViewer}> | ||||
| 					{renderEditor()} | ||||
| 					{renderViewer()} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</ErrorBoundary> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default forwardRef(CodeMirror); | ||||
| @@ -0,0 +1,103 @@ | ||||
| import * as React from 'react'; | ||||
| import { ForwardedRef } from 'react'; | ||||
| import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'; | ||||
| import { EditorProps, LogMessageCallback, OnEventCallback, PluginData } from '@joplin/editor/types'; | ||||
| import createEditor from '@joplin/editor/CodeMirror/createEditor'; | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { ContentScriptType } from '@joplin/lib/services/plugins/api/types'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import PluginService from '@joplin/lib/services/plugins/PluginService'; | ||||
| import setupVim from '../utils/setupVim'; | ||||
|  | ||||
| interface Props extends EditorProps { | ||||
| 	style: React.CSSProperties; | ||||
| 	pluginStates: PluginStates; | ||||
| } | ||||
|  | ||||
| const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => { | ||||
| 	const editorContainerRef = useRef<HTMLDivElement>(); | ||||
| 	const [editor, setEditor] = useState<CodeMirrorControl|null>(null); | ||||
|  | ||||
| 	// The editor will only be created once, so callbacks that could | ||||
| 	// change need to be stored as references. | ||||
| 	const onEventRef = useRef<OnEventCallback>(props.onEvent); | ||||
| 	const onLogMessageRef = useRef<LogMessageCallback>(props.onLogMessage); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		onEventRef.current = props.onEvent; | ||||
| 		onLogMessageRef.current = props.onLogMessage; | ||||
| 	}, [props.onEvent, props.onLogMessage]); | ||||
|  | ||||
| 	useImperativeHandle(ref, () => { | ||||
| 		return editor; | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const plugins: PluginData[] = []; | ||||
| 		for (const pluginId in props.pluginStates) { | ||||
| 			const pluginState = props.pluginStates[pluginId]; | ||||
| 			const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? []; | ||||
|  | ||||
| 			for (const contentScript of codeMirrorContentScripts) { | ||||
| 				plugins.push({ | ||||
| 					pluginId, | ||||
| 					contentScriptId: contentScript.id, | ||||
| 					contentScriptJs: () => shim.fsDriver().readFile(contentScript.path), | ||||
| 					postMessageHandler: (message: any) => { | ||||
| 						const plugin = PluginService.instance().pluginById(pluginId); | ||||
| 						return plugin.emitContentScriptMessage(contentScript.id, message); | ||||
| 					}, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		void editor.setPlugins(plugins); | ||||
| 	}, [editor, props.pluginStates]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editorContainerRef.current) return () => {}; | ||||
|  | ||||
| 		const editorProps: EditorProps = { | ||||
| 			...props, | ||||
| 			onEvent: event => onEventRef.current(event), | ||||
| 			onLogMessage: message => onLogMessageRef.current(message), | ||||
| 		}; | ||||
|  | ||||
| 		const editor = createEditor(editorContainerRef.current, editorProps); | ||||
| 		editor.addStyles({ | ||||
| 			'.cm-scroller': { overflow: 'auto' }, | ||||
| 		}); | ||||
| 		setEditor(editor); | ||||
|  | ||||
| 		return () => { | ||||
| 			editor.remove(); | ||||
| 		}; | ||||
| 	// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once | ||||
| 	}, []); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		editor?.updateSettings(props.settings); | ||||
| 	}, [props.settings, editor]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (!editor) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		setupVim(editor); | ||||
| 	}, [editor]); | ||||
|  | ||||
| 	return ( | ||||
| 		<div | ||||
| 			style={props.style} | ||||
| 			ref={editorContainerRef} | ||||
| 		></div> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default forwardRef(Editor); | ||||
| @@ -0,0 +1,136 @@ | ||||
|  | ||||
| import { RefObject, useMemo } from 'react'; | ||||
| import { CommandValue } from '../../../utils/types'; | ||||
| import { commandAttachFileToBody } from '../../../utils/resourceHandling'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import dialogs from '../../../../dialogs'; | ||||
| import { EditorCommandType } from '@joplin/editor/types'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
|  | ||||
| const logger = Logger.create('CodeMirror 6 commands'); | ||||
|  | ||||
| const wrapSelectionWithStrings = (editor: CodeMirrorControl, string1: string, string2 = '', defaultText = '') => { | ||||
| 	if (editor.somethingSelected()) { | ||||
| 		editor.wrapSelections(string1, string2); | ||||
| 	} else { | ||||
| 		editor.wrapSelections(string1 + defaultText, string2); | ||||
|  | ||||
| 		// Now select the default text so the user can replace it | ||||
| 		const selections = editor.listSelections(); | ||||
| 		const newSelections = []; | ||||
| 		for (let i = 0; i < selections.length; i++) { | ||||
| 			const s = selections[i]; | ||||
| 			const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length }; | ||||
| 			const head = { line: s.head.line, ch: s.head.ch - string2.length }; | ||||
| 			newSelections.push({ anchor: anchor, head: head }); | ||||
| 		} | ||||
| 		editor.setSelections(newSelections); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| interface Props { | ||||
| 	webviewRef: RefObject<any>; | ||||
| 	editorRef: RefObject<CodeMirrorControl>; | ||||
| 	editorContent: string; | ||||
|  | ||||
| 	editorCutText(): void; | ||||
| 	editorCopyText(): void; | ||||
| 	editorPaste(): void; | ||||
| 	selectionRange: { from: number; to: number }; | ||||
|  | ||||
| 	visiblePanes: string[]; | ||||
| } | ||||
|  | ||||
| const useEditorCommands = (props: Props) => { | ||||
| 	const editorRef = props.editorRef; | ||||
|  | ||||
| 	return useMemo(() => { | ||||
| 		const selectedText = () => { | ||||
| 			if (!editorRef.current) return ''; | ||||
| 			return editorRef.current.getSelection(); | ||||
| 		}; | ||||
|  | ||||
| 		return { | ||||
| 			dropItems: async (cmd: any) => { | ||||
| 				if (cmd.type === 'notes') { | ||||
| 					editorRef.current.insertText(cmd.markdownTags.join('\n')); | ||||
| 				} else if (cmd.type === 'files') { | ||||
| 					const pos = props.selectionRange.from; | ||||
| 					const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos }); | ||||
| 					editorRef.current.updateBody(newBody); | ||||
| 				} else { | ||||
| 					logger.warn('CodeMirror: unsupported drop item: ', cmd); | ||||
| 				} | ||||
| 			}, | ||||
| 			selectedText: () => { | ||||
| 				return selectedText(); | ||||
| 			}, | ||||
| 			selectedHtml: () => { | ||||
| 				return selectedText(); | ||||
| 			}, | ||||
| 			replaceSelection: (value: string) => { | ||||
| 				return editorRef.current.insertText(value); | ||||
| 			}, | ||||
| 			textCopy: () => { | ||||
| 				props.editorCopyText(); | ||||
| 			}, | ||||
| 			textCut: () => { | ||||
| 				props.editorCutText(); | ||||
| 			}, | ||||
| 			textPaste: () => { | ||||
| 				props.editorPaste(); | ||||
| 			}, | ||||
| 			textSelectAll: () => { | ||||
| 				return editorRef.current.execCommand(EditorCommandType.SelectAll); | ||||
| 			}, | ||||
| 			textLink: async () => { | ||||
| 				const url = await dialogs.prompt(_('Insert Hyperlink')); | ||||
| 				editorRef.current.focus(); | ||||
| 				if (url) wrapSelectionWithStrings(editorRef.current, '[', `](${url})`); | ||||
| 			}, | ||||
| 			insertText: (value: any) => editorRef.current.insertText(value), | ||||
| 			attachFile: async () => { | ||||
| 				const newBody = await commandAttachFileToBody( | ||||
| 					props.editorContent, null, { position: props.selectionRange.from }, | ||||
| 				); | ||||
| 				if (newBody) { | ||||
| 					editorRef.current.updateBody(newBody); | ||||
| 				} | ||||
| 			}, | ||||
| 			textHorizontalRule: () => editorRef.current.insertText('* * *'), | ||||
| 			'editor.execCommand': (value: CommandValue) => { | ||||
| 				if (!('args' in value)) value.args = []; | ||||
|  | ||||
| 				if ((editorRef.current as any)[value.name]) { | ||||
| 					const result = (editorRef.current as any)[value.name](...value.args); | ||||
| 					return result; | ||||
| 				} else if (editorRef.current.commandExists(value.name)) { | ||||
| 					const result = editorRef.current.execCommand(value.name); | ||||
| 					return result; | ||||
| 				} else { | ||||
| 					logger.warn('CodeMirror execCommand: unsupported command: ', value.name); | ||||
| 				} | ||||
| 			}, | ||||
| 			'editor.focus': () => { | ||||
| 				if (props.visiblePanes.indexOf('editor') >= 0) { | ||||
| 					editorRef.current.editor.focus(); | ||||
| 				} else { | ||||
| 					// If we just call focus() then the iframe is focused, | ||||
| 					// but not its content, such that scrolling up / down | ||||
| 					// with arrow keys fails | ||||
| 					props.webviewRef.current.send('focus'); | ||||
| 				} | ||||
| 			}, | ||||
| 			search: () => { | ||||
| 				editorRef.current.execCommand(EditorCommandType.ShowSearch); | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [ | ||||
| 		props.visiblePanes, props.editorContent, props.editorCopyText, props.editorCutText, props.editorPaste, | ||||
| 		props.selectionRange, | ||||
|  | ||||
| 		props.webviewRef, editorRef, | ||||
| 	]); | ||||
| }; | ||||
| export default useEditorCommands; | ||||
| @@ -1,7 +1,6 @@ | ||||
| import * as React from 'react'; | ||||
| import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; | ||||
| import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; | ||||
| import CodeMirror from './NoteBody/CodeMirror/CodeMirror'; | ||||
| import { connect } from 'react-redux'; | ||||
| import MultiNoteActions from '../MultiNoteActions'; | ||||
| import { htmlToMarkdown, formNoteToNote } from './utils'; | ||||
| @@ -46,6 +45,8 @@ import { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import BaseItem from '@joplin/lib/models/BaseItem'; | ||||
| import { ErrorCode } from '@joplin/lib/errors'; | ||||
| import ItemChange from '@joplin/lib/models/ItemChange'; | ||||
| import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror'; | ||||
| import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror'; | ||||
|  | ||||
| const commands = [ | ||||
| 	require('./commands/showRevisions'), | ||||
| @@ -380,7 +381,7 @@ function NoteEditor(props: NoteEditorProps) { | ||||
| 		}; | ||||
| 	}, [setShowRevisions]); | ||||
|  | ||||
| 	const onScroll = useCallback((event: any) => { | ||||
| 	const onScroll = useCallback((event: { percent: number }) => { | ||||
| 		props.dispatch({ | ||||
| 			type: 'EDITOR_SCROLL_PERCENT_SET', | ||||
| 			// In callbacks of setTimeout()/setInterval(), props/state cannot be used | ||||
| @@ -462,7 +463,9 @@ function NoteEditor(props: NoteEditorProps) { | ||||
| 	if (props.bodyEditor === 'TinyMCE') { | ||||
| 		editor = <TinyMCE {...editorProps}/>; | ||||
| 	} else if (props.bodyEditor === 'CodeMirror') { | ||||
| 		editor = <CodeMirror {...editorProps}/>; | ||||
| 		editor = <CodeMirror5 {...editorProps}/>; | ||||
| 	} else if (props.bodyEditor === 'CodeMirror6') { | ||||
| 		editor = <CodeMirror6 {...editorProps}/>; | ||||
| 	} else { | ||||
| 		throw new Error(`Invalid editor: ${props.bodyEditor}`); | ||||
| 	} | ||||
| @@ -602,7 +605,7 @@ function NoteEditor(props: NoteEditorProps) { | ||||
| 					disabled={isReadOnly} | ||||
| 				/> | ||||
| 				{renderSearchInfo()} | ||||
| 				<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}> | ||||
| 				<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}> | ||||
| 					{editor} | ||||
| 				</div> | ||||
| 				<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> | ||||
|   | ||||
| @@ -48,6 +48,15 @@ export interface NoteEditorProps { | ||||
| 	syncUserId: string; | ||||
| } | ||||
|  | ||||
| export interface NoteBodyEditorRef { | ||||
| 	content(): string|Promise<string>; | ||||
| 	resetScroll(): void; | ||||
| 	scrollTo(options: ScrollOptions): void; | ||||
|  | ||||
| 	supportsCommand(name: string): boolean; | ||||
| 	execCommand(command: CommandValue): Promise<void>; | ||||
| } | ||||
|  | ||||
| export interface NoteBodyEditorProps { | ||||
| 	style: any; | ||||
| 	ref: any; | ||||
| @@ -59,7 +68,7 @@ export interface NoteBodyEditorProps { | ||||
| 	onChange(event: OnChangeEvent): void; | ||||
| 	onWillChange(event: any): void; | ||||
| 	onMessage(event: any): void; | ||||
| 	onScroll(event: any): void; | ||||
| 	onScroll(event: { percent: number }): void; | ||||
| 	markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	htmlToMarkdown: Function; | ||||
|   | ||||
| @@ -143,6 +143,7 @@ | ||||
|     "@electron/remote": "2.0.11", | ||||
|     "@fortawesome/fontawesome-free": "5.15.4", | ||||
|     "@joeattardi/emoji-button": "4.6.4", | ||||
|     "@joplin/editor": "~2.13", | ||||
|     "@joplin/lib": "~2.13", | ||||
|     "@joplin/renderer": "~2.13", | ||||
|     "@joplin/utils": "~2.13", | ||||
|   | ||||
| @@ -1,59 +0,0 @@ | ||||
| /** | ||||
|  * @jest-environment jsdom | ||||
|  */ | ||||
|  | ||||
| import { EditorSettings } from '../types'; | ||||
| import { initCodeMirror } from './CodeMirror'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { forceParsing } from '@codemirror/language'; | ||||
| import loadLangauges from './testUtil/loadLanguages'; | ||||
|  | ||||
| import { expect, describe, it } from '@jest/globals'; | ||||
|  | ||||
|  | ||||
| const createEditorSettings = (themeId: number) => { | ||||
| 	const themeData = themeStyle(themeId); | ||||
| 	const editorSettings: EditorSettings = { | ||||
| 		katexEnabled: true, | ||||
| 		spellcheckEnabled: true, | ||||
| 		readOnly: false, | ||||
| 		themeId, | ||||
| 		themeData, | ||||
| 	}; | ||||
|  | ||||
| 	return editorSettings; | ||||
| }; | ||||
|  | ||||
| describe('CodeMirror', () => { | ||||
| 	// This checks for a regression -- occasionally, when updating packages, | ||||
| 	// syntax highlighting in the CodeMirror editor stops working. This is usually | ||||
| 	// fixed by | ||||
| 	// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock, | ||||
| 	// 2. upgrading all CodeMirror packages to the latest versions in package.json, and | ||||
| 	// 3. re-running `yarn install`. | ||||
| 	// | ||||
| 	// See https://github.com/laurent22/joplin/issues/7253 | ||||
| 	it('should give headings a different style', async () => { | ||||
| 		const headerLineText = '# Testing...'; | ||||
| 		const initialText = `${headerLineText}\nThis is a test.`; | ||||
| 		const editorSettings = createEditorSettings(Setting.THEME_LIGHT); | ||||
|  | ||||
| 		await loadLangauges(); | ||||
| 		const editor = initCodeMirror(document.body, initialText, editorSettings); | ||||
|  | ||||
| 		// Force the generation of the syntax tree now. | ||||
| 		forceParsing(editor.editor); | ||||
|  | ||||
| 		// CodeMirror nests the tag that styles the header within .cm-headerLine: | ||||
| 		//  <div class='cm-headerLine'><span class='someclass'>Testing...</span></div> | ||||
| 		const headerLineContent = document.body.querySelector('.cm-headerLine > span')!; | ||||
|  | ||||
|  | ||||
| 		expect(headerLineContent.textContent).toBe(headerLineText); | ||||
|  | ||||
| 		const style = getComputedStyle(headerLineContent); | ||||
| 		expect(style.borderBottom).not.toBe(''); | ||||
| 		expect(style.fontSize).toBe('1.6em'); | ||||
| 	}); | ||||
| }); | ||||
| @@ -9,448 +9,24 @@ | ||||
| // wrapper to access CodeMirror functionalities. Anything else should be done | ||||
| // from NoteEditor.tsx. | ||||
|  | ||||
| import { MarkdownMathExtension } from './markdownMathParser'; | ||||
| import createTheme from './theme'; | ||||
| import decoratorExtension from './decoratorExtension'; | ||||
|  | ||||
| import syntaxHighlightingLanguages from './syntaxHighlightingLanguages'; | ||||
|  | ||||
| import { EditorState } from '@codemirror/state'; | ||||
| import { markdown } from '@codemirror/lang-markdown'; | ||||
| import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; | ||||
| import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language'; | ||||
| import { | ||||
| 	openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery, | ||||
| 	/* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext, | ||||
| } from '@codemirror/search'; | ||||
|  | ||||
| import { | ||||
| 	EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, | ||||
| } from '@codemirror/view'; | ||||
| import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands'; | ||||
|  | ||||
| import { keymap, KeyBinding } from '@codemirror/view'; | ||||
| import { searchKeymap } from '@codemirror/search'; | ||||
| import { historyKeymap, defaultKeymap } from '@codemirror/commands'; | ||||
|  | ||||
| import { CodeMirrorControl } from './types'; | ||||
| import { EditorSettings, ListType, SearchState } from '../types'; | ||||
| import { ChangeEvent, SelectionChangeEvent, Selection } from '../types'; | ||||
| import SelectionFormatting from '../SelectionFormatting'; | ||||
| import { EditorSettings } from '@joplin/editor/types'; | ||||
| import createEditor from '@joplin/editor/CodeMirror/createEditor'; | ||||
| import { logMessage, postMessage } from './webviewLogger'; | ||||
| import { | ||||
| 	decreaseIndent, increaseIndent, | ||||
| 	toggleBolded, toggleCode, | ||||
| 	toggleHeaderLevel, toggleItalicized, | ||||
| 	toggleList, toggleMath, updateLink, | ||||
| } from './markdownCommands'; | ||||
|  | ||||
|  | ||||
| interface CodeMirrorResult extends CodeMirrorControl { | ||||
| 	editor: EditorView; | ||||
| } | ||||
| import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; | ||||
|  | ||||
| export function initCodeMirror( | ||||
| 	parentElement: any, initialText: string, settings: EditorSettings, | ||||
| ): CodeMirrorResult { | ||||
| 	logMessage('Initializing CodeMirror...'); | ||||
| 	const theme = settings.themeData; | ||||
| 	parentElement: HTMLElement, initialText: string, settings: EditorSettings, | ||||
| ): CodeMirrorControl { | ||||
| 	return createEditor(parentElement, { | ||||
| 		initialText, | ||||
| 		settings, | ||||
|  | ||||
| 	let searchVisible = false; | ||||
|  | ||||
| 	let schedulePostUndoRedoDepthChangeId_: any = 0; | ||||
| 	const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => { | ||||
| 		if (schedulePostUndoRedoDepthChangeId_) { | ||||
| 			if (doItNow) { | ||||
| 				clearTimeout(schedulePostUndoRedoDepthChangeId_); | ||||
| 			} else { | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		schedulePostUndoRedoDepthChangeId_ = setTimeout(() => { | ||||
| 			schedulePostUndoRedoDepthChangeId_ = null; | ||||
| 			postMessage('onUndoRedoDepthChange', { | ||||
| 				undoDepth: undoDepth(editor.state), | ||||
| 				redoDepth: redoDepth(editor.state), | ||||
| 			}); | ||||
| 		}, doItNow ? 0 : 1000); | ||||
| 	}; | ||||
|  | ||||
| 	const notifyDocChanged = (viewUpdate: ViewUpdate) => { | ||||
| 		if (viewUpdate.docChanged) { | ||||
| 			const event: ChangeEvent = { | ||||
| 				value: editor.state.doc.toString(), | ||||
| 			}; | ||||
|  | ||||
| 			postMessage('onChange', event); | ||||
| 			schedulePostUndoRedoDepthChange(editor); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const notifyLinkEditRequest = () => { | ||||
| 		postMessage('onRequestLinkEdit', null); | ||||
| 	}; | ||||
|  | ||||
| 	const showSearchDialog = () => { | ||||
| 		const query = getSearchQuery(editor.state); | ||||
| 		const searchState: SearchState = { | ||||
| 			searchText: query.search, | ||||
| 			replaceText: query.replace, | ||||
| 			useRegex: query.regexp, | ||||
| 			caseSensitive: query.caseSensitive, | ||||
| 			dialogVisible: true, | ||||
| 		}; | ||||
|  | ||||
| 		postMessage('onRequestShowSearch', searchState); | ||||
| 		searchVisible = true; | ||||
| 	}; | ||||
|  | ||||
| 	const hideSearchDialog = () => { | ||||
| 		postMessage('onRequestHideSearch', null); | ||||
| 		searchVisible = false; | ||||
| 	}; | ||||
|  | ||||
| 	const notifySelectionChange = (viewUpdate: ViewUpdate) => { | ||||
| 		if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { | ||||
| 			const mainRange = viewUpdate.state.selection.main; | ||||
| 			const selection: Selection = { | ||||
| 				start: mainRange.from, | ||||
| 				end: mainRange.to, | ||||
| 			}; | ||||
| 			const event: SelectionChangeEvent = { | ||||
| 				selection, | ||||
| 			}; | ||||
| 			postMessage('onSelectionChange', event); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => { | ||||
| 		// If we can't determine the previous formatting, post the update regardless | ||||
| 		if (!viewUpdate) { | ||||
| 			const formatting = computeSelectionFormatting(editor.state); | ||||
| 			postMessage('onSelectionFormattingChange', formatting.toJSON()); | ||||
| 		} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { | ||||
| 			// Only post the update if something changed | ||||
| 			const oldFormatting = computeSelectionFormatting(viewUpdate.startState); | ||||
| 			const newFormatting = computeSelectionFormatting(viewUpdate.state); | ||||
|  | ||||
| 			if (!oldFormatting.eq(newFormatting)) { | ||||
| 				postMessage('onSelectionFormattingChange', newFormatting.toJSON()); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const computeSelectionFormatting = (state: EditorState): SelectionFormatting => { | ||||
| 		const range = state.selection.main; | ||||
| 		const formatting: SelectionFormatting = new SelectionFormatting(); | ||||
| 		formatting.selectedText = state.doc.sliceString(range.from, range.to); | ||||
| 		formatting.spellChecking = editor.contentDOM.spellcheck; | ||||
|  | ||||
| 		const parseLinkData = (nodeText: string) => { | ||||
| 			const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/); | ||||
|  | ||||
| 			if (linkMatch) { | ||||
| 				return { | ||||
| 					linkText: linkMatch[1], | ||||
| 					linkURL: linkMatch[2], | ||||
| 				}; | ||||
| 			} | ||||
|  | ||||
| 			return null; | ||||
| 		}; | ||||
|  | ||||
| 		// Find nodes that overlap/are within the selected region | ||||
| 		syntaxTree(state).iterate({ | ||||
| 			from: range.from, to: range.to, | ||||
| 			enter: node => { | ||||
| 				// Checklists don't have a specific containing node. As such, | ||||
| 				// we're in a checklist if we've selected a 'Task' node. | ||||
| 				if (node.name === 'Task') { | ||||
| 					formatting.inChecklist = true; | ||||
| 				} | ||||
|  | ||||
| 				// Only handle notes that contain the entire range. | ||||
| 				if (node.from > range.from || node.to < range.to) { | ||||
| 					return; | ||||
| 				} | ||||
| 				// Lazily compute the node's text | ||||
| 				const nodeText = () => state.doc.sliceString(node.from, node.to); | ||||
|  | ||||
| 				switch (node.name) { | ||||
| 				case 'StrongEmphasis': | ||||
| 					formatting.bolded = true; | ||||
| 					break; | ||||
| 				case 'Emphasis': | ||||
| 					formatting.italicized = true; | ||||
| 					break; | ||||
| 				case 'ListItem': | ||||
| 					formatting.listLevel += 1; | ||||
| 					break; | ||||
| 				case 'BulletList': | ||||
| 					formatting.inUnorderedList = true; | ||||
| 					break; | ||||
| 				case 'OrderedList': | ||||
| 					formatting.inOrderedList = true; | ||||
| 					break; | ||||
| 				case 'TaskList': | ||||
| 					formatting.inChecklist = true; | ||||
| 					break; | ||||
| 				case 'InlineCode': | ||||
| 				case 'FencedCode': | ||||
| 					formatting.inCode = true; | ||||
| 					formatting.unspellCheckableRegion = true; | ||||
| 					break; | ||||
| 				case 'InlineMath': | ||||
| 				case 'BlockMath': | ||||
| 					formatting.inMath = true; | ||||
| 					formatting.unspellCheckableRegion = true; | ||||
| 					break; | ||||
| 				case 'ATXHeading1': | ||||
| 					formatting.headerLevel = 1; | ||||
| 					break; | ||||
| 				case 'ATXHeading2': | ||||
| 					formatting.headerLevel = 2; | ||||
| 					break; | ||||
| 				case 'ATXHeading3': | ||||
| 					formatting.headerLevel = 3; | ||||
| 					break; | ||||
| 				case 'ATXHeading4': | ||||
| 					formatting.headerLevel = 4; | ||||
| 					break; | ||||
| 				case 'ATXHeading5': | ||||
| 					formatting.headerLevel = 5; | ||||
| 					break; | ||||
| 				case 'URL': | ||||
| 					formatting.inLink = true; | ||||
| 					formatting.linkData.linkURL = nodeText(); | ||||
| 					formatting.unspellCheckableRegion = true; | ||||
| 					break; | ||||
| 				case 'Link': | ||||
| 					formatting.inLink = true; | ||||
| 					formatting.linkData = parseLinkData(nodeText()); | ||||
| 					break; | ||||
| 				} | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		// The markdown parser marks checklists as unordered lists. Ensure | ||||
| 		// that they aren't marked as such. | ||||
| 		if (formatting.inChecklist) { | ||||
| 			if (!formatting.inUnorderedList) { | ||||
| 				// Even if the selection contains a Task, because an unordered list node | ||||
| 				// must contain a valid Task node, we're only in a checklist if we're also in | ||||
| 				// an unordered list. | ||||
| 				formatting.inChecklist = false; | ||||
| 			} else { | ||||
| 				formatting.inUnorderedList = false; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (formatting.unspellCheckableRegion) { | ||||
| 			formatting.spellChecking = false; | ||||
| 		} | ||||
|  | ||||
| 		return formatting; | ||||
| 	}; | ||||
|  | ||||
| 	// Returns a keyboard command that returns true (so accepts the keybind) | ||||
| 	const keyCommand = (key: string, run: Command): KeyBinding => { | ||||
| 		return { | ||||
| 			key, | ||||
| 			run, | ||||
| 			preventDefault: true, | ||||
| 		}; | ||||
| 	}; | ||||
|  | ||||
| 	const editor = new EditorView({ | ||||
| 		state: EditorState.create({ | ||||
| 			// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts | ||||
| 			// for a sample configuration. | ||||
| 			extensions: [ | ||||
| 				markdown({ | ||||
| 					extensions: [ | ||||
| 						GitHubFlavoredMarkdownExtension, | ||||
|  | ||||
| 						// Don't highlight KaTeX if the user disabled it | ||||
| 						settings.katexEnabled ? MarkdownMathExtension : [], | ||||
| 					], | ||||
| 					codeLanguages: syntaxHighlightingLanguages, | ||||
| 				}), | ||||
| 				...createTheme(theme), | ||||
| 				history(), | ||||
| 				search({ | ||||
| 					createPanel(_: EditorView) { | ||||
| 						return { | ||||
| 							// The actual search dialog is implemented with react native, | ||||
| 							// use a dummy element. | ||||
| 							dom: document.createElement('div'), | ||||
| 							mount() { | ||||
| 								showSearchDialog(); | ||||
| 							}, | ||||
| 							destroy() { | ||||
| 								hideSearchDialog(); | ||||
| 							}, | ||||
| 						}; | ||||
| 					}, | ||||
| 				}), | ||||
| 				drawSelection(), | ||||
| 				highlightSpecialChars(), | ||||
| 				// highlightSelectionMatches(), | ||||
| 				indentOnInput(), | ||||
|  | ||||
| 				// By default, indent with four spaces | ||||
| 				indentUnit.of('    '), | ||||
| 				EditorState.tabSize.of(4), | ||||
|  | ||||
| 				// Apply styles to entire lines (block-display decorations) | ||||
| 				decoratorExtension, | ||||
|  | ||||
| 				EditorView.lineWrapping, | ||||
| 				EditorView.contentAttributes.of({ | ||||
| 					autocapitalize: 'sentence', | ||||
| 					autocorrect: settings.spellcheckEnabled ? 'true' : 'false', | ||||
| 					spellcheck: settings.spellcheckEnabled ? 'true' : 'false', | ||||
| 				}), | ||||
| 				EditorView.updateListener.of((viewUpdate: ViewUpdate) => { | ||||
| 					notifyDocChanged(viewUpdate); | ||||
| 					notifySelectionChange(viewUpdate); | ||||
| 					notifySelectionFormattingChange(viewUpdate); | ||||
| 				}), | ||||
| 				keymap.of([ | ||||
| 					// Custom mod-f binding: Toggle the external dialog implementation | ||||
| 					// (don't show/hide the Panel dialog). | ||||
| 					keyCommand('Mod-f', (_: EditorView) => { | ||||
| 						if (searchVisible) { | ||||
| 							hideSearchDialog(); | ||||
| 						} else { | ||||
| 							showSearchDialog(); | ||||
| 						} | ||||
| 						return true; | ||||
| 					}), | ||||
| 					// Markdown formatting keyboard shortcuts | ||||
| 					keyCommand('Mod-b', toggleBolded), | ||||
| 					keyCommand('Mod-i', toggleItalicized), | ||||
| 					keyCommand('Mod-$', toggleMath), | ||||
| 					keyCommand('Mod-`', toggleCode), | ||||
| 					keyCommand('Mod-[', decreaseIndent), | ||||
| 					keyCommand('Mod-]', increaseIndent), | ||||
| 					keyCommand('Mod-k', (_: EditorView) => { | ||||
| 						notifyLinkEditRequest(); | ||||
| 						return true; | ||||
| 					}), | ||||
|  | ||||
| 					...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap, | ||||
| 				]), | ||||
|  | ||||
| 				EditorState.readOnly.of(settings.readOnly), | ||||
| 			], | ||||
| 			doc: initialText, | ||||
| 		}), | ||||
| 		parent: parentElement, | ||||
| 		onLogMessage: message => { | ||||
| 			logMessage(message); | ||||
| 		}, | ||||
| 		onEvent: (event): void => { | ||||
| 			postMessage('onEditorEvent', event); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	// HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown | ||||
| 	//  event to prevent the Editor's .preventDefault from making the context menu not appear. | ||||
| 	// TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround | ||||
| 	//  when the upstream bug is fixed. | ||||
| 	document.body.addEventListener('mousedown', (evt) => { | ||||
| 		if (!evt.isTrusted) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Walk up the tree -- is evt.target or any of its parent nodes the editor's input region? | ||||
| 		for (let current: Record<string, any> = evt.target; current; current = current.parentElement) { | ||||
| 			if (current === editor.contentDOM) { | ||||
| 				evt.stopPropagation(); | ||||
|  | ||||
| 				const copyEvent = new Event('mousedown', evt); | ||||
| 				editor.contentDOM.dispatchEvent(copyEvent); | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
| 	}, true); | ||||
|  | ||||
| 	const updateSearchQuery = (newState: SearchState) => { | ||||
| 		const query = new SearchQuery({ | ||||
| 			search: newState.searchText, | ||||
| 			caseSensitive: newState.caseSensitive, | ||||
| 			regexp: newState.useRegex, | ||||
| 			replace: newState.replaceText, | ||||
| 		}); | ||||
| 		editor.dispatch({ | ||||
| 			effects: setSearchQuery.of(query), | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const editorControls = { | ||||
| 		editor, | ||||
| 		undo: () => { | ||||
| 			undo(editor); | ||||
| 			schedulePostUndoRedoDepthChange(editor, true); | ||||
| 		}, | ||||
| 		redo: () => { | ||||
| 			redo(editor); | ||||
| 			schedulePostUndoRedoDepthChange(editor, true); | ||||
| 		}, | ||||
| 		select: (anchor: number, head: number) => { | ||||
| 			editor.dispatch(editor.state.update({ | ||||
| 				selection: { anchor, head }, | ||||
| 				scrollIntoView: true, | ||||
| 			})); | ||||
| 		}, | ||||
| 		scrollSelectionIntoView: () => { | ||||
| 			editor.dispatch(editor.state.update({ | ||||
| 				scrollIntoView: true, | ||||
| 			})); | ||||
| 		}, | ||||
| 		insertText: (text: string) => { | ||||
| 			editor.dispatch(editor.state.replaceSelection(text)); | ||||
| 		}, | ||||
| 		toggleFindDialog: () => { | ||||
| 			const opened = openSearchPanel(editor); | ||||
| 			if (!opened) { | ||||
| 				closeSearchPanel(editor); | ||||
| 			} | ||||
| 		}, | ||||
|  | ||||
| 		// Formatting | ||||
| 		toggleBolded: () => { toggleBolded(editor); }, | ||||
| 		toggleItalicized: () => { toggleItalicized(editor); }, | ||||
| 		toggleCode: () => { toggleCode(editor); }, | ||||
| 		toggleMath: () => { toggleMath(editor); }, | ||||
| 		increaseIndent: () => { increaseIndent(editor); }, | ||||
| 		decreaseIndent: () => { decreaseIndent(editor); }, | ||||
| 		toggleList: (kind: ListType) => { toggleList(kind)(editor); }, | ||||
| 		toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); }, | ||||
| 		updateLink: (label: string, url: string) => { updateLink(label, url)(editor); }, | ||||
|  | ||||
| 		// Search | ||||
| 		searchControl: { | ||||
| 			findNext: () => { | ||||
| 				findNext(editor); | ||||
| 			}, | ||||
| 			findPrevious: () => { | ||||
| 				findPrevious(editor); | ||||
| 			}, | ||||
| 			replaceCurrent: () => { | ||||
| 				replaceNext(editor); | ||||
| 			}, | ||||
| 			replaceAll: () => { | ||||
| 				replaceAll(editor); | ||||
| 			}, | ||||
| 			setSearchState: (state: SearchState) => { | ||||
| 				updateSearchQuery(state); | ||||
| 			}, | ||||
| 			showSearch: () => { | ||||
| 				showSearchDialog(); | ||||
| 			}, | ||||
| 			hideSearch: () => { | ||||
| 				hideSearchDialog(); | ||||
| 			}, | ||||
| 		}, | ||||
| 	}; | ||||
|  | ||||
| 	return editorControls; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,27 +0,0 @@ | ||||
| import { ListType, SearchControl } from '../types'; | ||||
|  | ||||
| // Controls for the CodeMirror portion of the editor | ||||
| export interface CodeMirrorControl { | ||||
| 	undo(): void; | ||||
| 	redo(): void; | ||||
| 	select(anchor: number, head: number): void; | ||||
| 	insertText(text: string): void; | ||||
|  | ||||
| 	// Toggle whether we're in a type of region. | ||||
| 	toggleBolded(): void; | ||||
| 	toggleItalicized(): void; | ||||
| 	toggleList(kind: ListType): void; | ||||
| 	toggleCode(): void; | ||||
| 	toggleMath(): void; | ||||
| 	toggleHeaderLevel(level: number): void; | ||||
|  | ||||
| 	// Create a new link or update the currently selected link with | ||||
| 	// the given [label] and [url]. | ||||
| 	updateLink(label: string, url: string): void; | ||||
|  | ||||
| 	increaseIndent(): void; | ||||
| 	decreaseIndent(): void; | ||||
| 	scrollSelectionIntoView(): void; | ||||
|  | ||||
| 	searchControl: SearchControl; | ||||
| } | ||||
| @@ -9,8 +9,8 @@ import Modal from '../Modal'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { EditorControl } from './types'; | ||||
| import SelectionFormatting from './SelectionFormatting'; | ||||
| import { useCallback } from 'react'; | ||||
| import SelectionFormatting from '@joplin/editor/SelectionFormatting'; | ||||
|  | ||||
| interface LinkDialogProps { | ||||
| 	editorControl: EditorControl; | ||||
| @@ -21,7 +21,7 @@ interface LinkDialogProps { | ||||
|  | ||||
| const EditLinkDialog = (props: LinkDialogProps) => { | ||||
| 	// The content of the link selected in the editor (if any) | ||||
| 	const editorLinkData = props.selectionState.linkData ?? {}; | ||||
| 	const editorLinkData = props.selectionState.linkData; | ||||
| 	const [linkLabel, setLinkLabel] = useState(''); | ||||
| 	const [linkURL, setLinkURL] = useState(''); | ||||
|  | ||||
|   | ||||
| @@ -14,13 +14,14 @@ import { _ } from '@joplin/lib/locale'; | ||||
| import time from '@joplin/lib/time'; | ||||
| import { useEffect } from 'react'; | ||||
| import { Keyboard, ViewStyle } from 'react-native'; | ||||
| import { EditorControl, EditorSettings, ListType, SearchState } from '../types'; | ||||
| import SelectionFormatting from '../SelectionFormatting'; | ||||
| import { EditorControl, EditorSettings } from '../types'; | ||||
| import { ButtonSpec, StyleSheetData } from './types'; | ||||
| import Toolbar from './Toolbar'; | ||||
| import { buttonSize } from './ToolbarButton'; | ||||
| import { Theme } from '@joplin/lib/themes/type'; | ||||
| import ToggleSpaceButton from './ToggleSpaceButton'; | ||||
| import { SearchState } from '@joplin/editor/types'; | ||||
| import SelectionFormatting from '@joplin/editor/SelectionFormatting'; | ||||
|  | ||||
| type OnAttachCallback = ()=> void; | ||||
|  | ||||
| @@ -71,9 +72,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { | ||||
| 		), | ||||
| 		description: _('Unordered list'), | ||||
| 		active: selState.inUnorderedList, | ||||
| 		onPress: useCallback(() => { | ||||
| 			editorControl.toggleList(ListType.UnorderedList); | ||||
| 		}, [editorControl]), | ||||
| 		onPress: editorControl.toggleUnorderedList, | ||||
|  | ||||
| 		priority: -2, | ||||
| 		disabled: readOnly, | ||||
| @@ -85,9 +84,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { | ||||
| 		), | ||||
| 		description: _('Ordered list'), | ||||
| 		active: selState.inOrderedList, | ||||
| 		onPress: useCallback(() => { | ||||
| 			editorControl.toggleList(ListType.OrderedList); | ||||
| 		}, [editorControl]), | ||||
| 		onPress: editorControl.toggleOrderedList, | ||||
|  | ||||
| 		priority: -2, | ||||
| 		disabled: readOnly, | ||||
| @@ -99,9 +96,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { | ||||
| 		), | ||||
| 		description: _('Task list'), | ||||
| 		active: selState.inChecklist, | ||||
| 		onPress: useCallback(() => { | ||||
| 			editorControl.toggleList(ListType.CheckList); | ||||
| 		}, [editorControl]), | ||||
| 		onPress: editorControl.toggleTaskList, | ||||
|  | ||||
| 		priority: -2, | ||||
| 		disabled: readOnly, | ||||
|   | ||||
| @@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog'; | ||||
| import { defaultSearchState, SearchPanel } from './SearchPanel'; | ||||
| import ExtendedWebView from '../ExtendedWebView'; | ||||
|  | ||||
| const React = require('react'); | ||||
| import * as React from 'react'; | ||||
| import { forwardRef, RefObject, useImperativeHandle } from 'react'; | ||||
| import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; | ||||
| import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; | ||||
| const { editorFont } = require('../global-style'); | ||||
|  | ||||
| import SelectionFormatting from './SelectionFormatting'; | ||||
| import { | ||||
| 	EditorSettings, EditorControl, | ||||
| 	ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState, | ||||
| } from './types'; | ||||
| import { EditorControl, EditorSettings, SelectionRange } from './types'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar'; | ||||
| import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; | ||||
| import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types'; | ||||
| import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand'; | ||||
| import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting'; | ||||
|  | ||||
| type ChangeEventHandler = (event: ChangeEvent)=> void; | ||||
| type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void; | ||||
| type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void; | ||||
| type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void; | ||||
| type OnAttachCallback = ()=> void; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	initialText: string; | ||||
| 	initialSelection?: Selection; | ||||
| 	initialSelection?: SelectionRange; | ||||
| 	style: ViewStyle; | ||||
| 	contentStyle?: ViewStyle; | ||||
| 	toolbarEnabled: boolean; | ||||
| @@ -110,6 +110,11 @@ function editorTheme(themeId: number) { | ||||
|  | ||||
| 	return { | ||||
| 		...themeStyle(themeId), | ||||
|  | ||||
| 		// To allow accessibility font scaling, we also need to set the | ||||
| 		// fontSize to a value in `em`s (relative scaling relative to | ||||
| 		// parent font size). | ||||
| 		fontSizeUnits: 'em', | ||||
| 		fontSize: estimatedFontSizeInEm, | ||||
| 		fontFamily: fontFamilyFromSettings(), | ||||
| 	}; | ||||
| @@ -120,48 +125,92 @@ type OnSetVisibleCallback = (visible: boolean)=> void; | ||||
| type OnSearchStateChangeCallback = (state: SearchState)=> void; | ||||
| const useEditorControl = ( | ||||
| 	injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback, | ||||
| 	setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>, | ||||
| 	setSearchState: OnSearchStateChangeCallback, | ||||
| 	searchStateRef: RefObject<SearchState>, | ||||
| ): EditorControl => { | ||||
| 	return useMemo(() => { | ||||
| 		return { | ||||
| 		const execCommand = (command: EditorCommandType) => { | ||||
| 			injectJS(`cm.execCommand(${JSON.stringify(command)})`); | ||||
| 		}; | ||||
|  | ||||
| 		const setSearchStateCallback = (state: SearchState) => { | ||||
| 			injectJS(`cm.setSearchState(${JSON.stringify(state)})`); | ||||
| 			setSearchState(state); | ||||
| 		}; | ||||
|  | ||||
| 		const control: EditorControl = { | ||||
| 			supportsCommand(command: EditorCommandType) { | ||||
| 				return supportsCommand(command); | ||||
| 			}, | ||||
| 			execCommand, | ||||
|  | ||||
| 			undo() { | ||||
| 				injectJS('cm.undo();'); | ||||
| 				injectJS('cm.undo()'); | ||||
| 			}, | ||||
| 			redo() { | ||||
| 				injectJS('cm.redo();'); | ||||
| 				injectJS('cm.redo()'); | ||||
| 			}, | ||||
| 			select(anchor: number, head: number) { | ||||
| 				injectJS( | ||||
| 					`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`, | ||||
| 				); | ||||
| 			}, | ||||
| 			setScrollPercent(fraction: number) { | ||||
| 				injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`); | ||||
| 			}, | ||||
| 			insertText(text: string) { | ||||
| 				injectJS(`cm.insertText(${JSON.stringify(text)});`); | ||||
| 			}, | ||||
| 			updateBody(newBody: string) { | ||||
| 				injectJS(`cm.updateBody(${JSON.stringify(newBody)});`); | ||||
| 			}, | ||||
| 			updateSettings(newSettings: EditorSettings) { | ||||
| 				injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`); | ||||
| 			}, | ||||
|  | ||||
| 			toggleBolded() { | ||||
| 				injectJS('cm.toggleBolded();'); | ||||
| 				execCommand(EditorCommandType.ToggleBolded); | ||||
| 			}, | ||||
| 			toggleItalicized() { | ||||
| 				injectJS('cm.toggleItalicized();'); | ||||
| 				execCommand(EditorCommandType.ToggleItalicized); | ||||
| 			}, | ||||
| 			toggleList(listType: ListType) { | ||||
| 				injectJS(`cm.toggleList(${JSON.stringify(listType)});`); | ||||
| 			toggleOrderedList() { | ||||
| 				execCommand(EditorCommandType.ToggleNumberedList); | ||||
| 			}, | ||||
| 			toggleUnorderedList() { | ||||
| 				execCommand(EditorCommandType.ToggleCheckList); | ||||
| 			}, | ||||
| 			toggleTaskList() { | ||||
| 				execCommand(EditorCommandType.ToggleCheckList); | ||||
| 			}, | ||||
| 			toggleCode() { | ||||
| 				injectJS('cm.toggleCode();'); | ||||
| 				execCommand(EditorCommandType.ToggleCode); | ||||
| 			}, | ||||
| 			toggleMath() { | ||||
| 				injectJS('cm.toggleMath();'); | ||||
| 				execCommand(EditorCommandType.ToggleMath); | ||||
| 			}, | ||||
| 			toggleHeaderLevel(level: number) { | ||||
| 				injectJS(`cm.toggleHeaderLevel(${level});`); | ||||
| 				const levelToCommand = [ | ||||
| 					EditorCommandType.ToggleHeading1, | ||||
| 					EditorCommandType.ToggleHeading2, | ||||
| 					EditorCommandType.ToggleHeading3, | ||||
| 					EditorCommandType.ToggleHeading4, | ||||
| 					EditorCommandType.ToggleHeading5, | ||||
| 				]; | ||||
|  | ||||
| 				const index = level - 1; | ||||
|  | ||||
| 				if (index < 0 || index >= levelToCommand.length) { | ||||
| 					throw new Error(`Unsupported header level ${level}`); | ||||
| 				} | ||||
|  | ||||
| 				execCommand(levelToCommand[index]); | ||||
| 			}, | ||||
| 			increaseIndent() { | ||||
| 				injectJS('cm.increaseIndent();'); | ||||
| 				execCommand(EditorCommandType.IndentMore); | ||||
| 			}, | ||||
| 			decreaseIndent() { | ||||
| 				injectJS('cm.decreaseIndent();'); | ||||
| 				execCommand(EditorCommandType.IndentLess); | ||||
| 			}, | ||||
| 			updateLink(label: string, url: string) { | ||||
| 				injectJS(`cm.updateLink( | ||||
| @@ -170,7 +219,7 @@ const useEditorControl = ( | ||||
| 				);`); | ||||
| 			}, | ||||
| 			scrollSelectionIntoView() { | ||||
| 				injectJS('cm.scrollSelectionIntoView();'); | ||||
| 				execCommand(EditorCommandType.ScrollSelectionIntoView); | ||||
| 			}, | ||||
| 			showLinkDialog() { | ||||
| 				setLinkDialogVisible(true); | ||||
| @@ -181,23 +230,27 @@ const useEditorControl = ( | ||||
| 			hideKeyboard() { | ||||
| 				injectJS('document.activeElement?.blur();'); | ||||
| 			}, | ||||
|  | ||||
| 			setPlugins: async (plugins: PluginData[]) => { | ||||
| 				injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`); | ||||
| 			}, | ||||
|  | ||||
| 			setSearchState: setSearchStateCallback, | ||||
|  | ||||
| 			searchControl: { | ||||
| 				findNext() { | ||||
| 					injectJS('cm.searchControl.findNext();'); | ||||
| 					execCommand(EditorCommandType.FindNext); | ||||
| 				}, | ||||
| 				findPrevious() { | ||||
| 					injectJS('cm.searchControl.findPrevious();'); | ||||
| 					execCommand(EditorCommandType.FindPrevious); | ||||
| 				}, | ||||
| 				replaceCurrent() { | ||||
| 					injectJS('cm.searchControl.replaceCurrent();'); | ||||
| 				replaceNext() { | ||||
| 					execCommand(EditorCommandType.ReplaceNext); | ||||
| 				}, | ||||
| 				replaceAll() { | ||||
| 					injectJS('cm.searchControl.replaceAll();'); | ||||
| 				}, | ||||
| 				setSearchState(state: SearchState) { | ||||
| 					injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`); | ||||
| 					setSearchState(state); | ||||
| 					execCommand(EditorCommandType.ReplaceAll); | ||||
| 				}, | ||||
|  | ||||
| 				showSearch() { | ||||
| 					setSearchState({ | ||||
| 						...searchStateRef.current, | ||||
| @@ -210,8 +263,12 @@ const useEditorControl = ( | ||||
| 						dialogVisible: false, | ||||
| 					}); | ||||
| 				}, | ||||
|  | ||||
| 				setSearchState: setSearchStateCallback, | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
| 		return control; | ||||
| 	}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]); | ||||
| }; | ||||
|  | ||||
| @@ -227,7 +284,16 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 		themeData: editorTheme(props.themeId), | ||||
| 		katexEnabled: Setting.value('markdown.plugin.katex'), | ||||
| 		spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'), | ||||
| 		language: EditorLanguageType.Markdown, | ||||
| 		useExternalSearch: true, | ||||
| 		readOnly: props.readOnly, | ||||
|  | ||||
| 		keymap: EditorKeymap.Default, | ||||
|  | ||||
| 		automatchBraces: false, | ||||
| 		ignoreModifiers: false, | ||||
|  | ||||
| 		indentWithTabs: false, | ||||
| 	}; | ||||
|  | ||||
| 	const injectedJavaScript = ` | ||||
| @@ -252,6 +318,12 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 			); | ||||
| 		}; | ||||
|  | ||||
| 		window.onunhandledrejection = (event) => { | ||||
| 			window.ReactNativeWebView.postMessage( | ||||
| 				"error: Unhandled promise rejection: " + event | ||||
| 			); | ||||
| 		}; | ||||
|  | ||||
| 		if (!window.cm) { | ||||
| 			// This variable is not used within this script | ||||
| 			// but is called using "injectJavaScript" from | ||||
| @@ -269,7 +341,7 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 				${setInitialSelectionJS} | ||||
|  | ||||
| 				window.onresize = () => { | ||||
| 					cm.scrollSelectionIntoView(); | ||||
| 					cm.execCommand('scrollSelectionIntoView'); | ||||
| 				}; | ||||
| 			} catch (e) { | ||||
| 				window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e)) | ||||
| @@ -280,7 +352,7 @@ function NoteEditor(props: Props, ref: any) { | ||||
|  | ||||
| 	const css = useCss(props.themeId); | ||||
| 	const html = useHtml(css); | ||||
| 	const [selectionState, setSelectionState] = useState(new SelectionFormatting()); | ||||
| 	const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting); | ||||
| 	const [linkDialogVisible, setLinkDialogVisible] = useState(false); | ||||
| 	const [searchState, setSearchState] = useState(defaultSearchState); | ||||
|  | ||||
| @@ -293,7 +365,7 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 		searchStateRef.current = searchState; | ||||
| 	}, [searchState]); | ||||
|  | ||||
| 	// / Runs [js] in the context of the CodeMirror frame. | ||||
| 	// Runs [js] in the context of the CodeMirror frame. | ||||
| 	const injectJS = (js: string) => { | ||||
| 		webviewRef.current.injectJS(js); | ||||
| 	}; | ||||
| @@ -323,36 +395,41 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 				console.info('CodeMirror:', ...event.value); | ||||
| 			}, | ||||
|  | ||||
| 			onChange: (event: ChangeEvent) => { | ||||
| 				props.onChange(event); | ||||
| 			}, | ||||
| 			onEditorEvent: (event: EditorEvent) => { | ||||
| 				let exhaustivenessCheck: never; | ||||
| 				switch (event.kind) { | ||||
| 				case EditorEventType.Change: | ||||
| 					props.onChange(event); | ||||
| 					break; | ||||
| 				case EditorEventType.UndoRedoDepthChange: | ||||
| 					props.onUndoRedoDepthChange(event); | ||||
| 					break; | ||||
| 				case EditorEventType.SelectionRangeChange: | ||||
| 					props.onSelectionChange(event); | ||||
| 					break; | ||||
| 				case EditorEventType.SelectionFormattingChange: | ||||
| 					setSelectionState(event.formatting); | ||||
| 					break; | ||||
| 				case EditorEventType.EditLink: | ||||
| 					editorControl.showLinkDialog(); | ||||
| 					break; | ||||
| 				case EditorEventType.UpdateSearchDialog: | ||||
| 					setSearchState(event.searchState); | ||||
|  | ||||
| 			onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => { | ||||
| 				props.onUndoRedoDepthChange(event); | ||||
| 			}, | ||||
|  | ||||
| 			onSelectionChange: (event: SelectionChangeEvent) => { | ||||
| 				props.onSelectionChange(event); | ||||
| 			}, | ||||
|  | ||||
| 			onSelectionFormattingChange(data: string) { | ||||
| 				// We want a SelectionFormatting object, so are | ||||
| 				// instantiating it from JSON. | ||||
| 				const formatting = SelectionFormatting.fromJSON(data); | ||||
| 				setSelectionState(formatting); | ||||
| 			}, | ||||
|  | ||||
| 			onRequestLinkEdit() { | ||||
| 				editorControl.showLinkDialog(); | ||||
| 			}, | ||||
|  | ||||
| 			onRequestShowSearch(data: SearchState) { | ||||
| 				setSearchState(data); | ||||
| 				editorControl.searchControl.showSearch(); | ||||
| 			}, | ||||
|  | ||||
| 			onRequestHideSearch() { | ||||
| 				editorControl.searchControl.hideSearch(); | ||||
| 					if (event.searchState.dialogVisible) { | ||||
| 						editorControl.searchControl.showSearch(); | ||||
| 					} else { | ||||
| 						editorControl.searchControl.hideSearch(); | ||||
| 					} | ||||
| 					break; | ||||
| 				case EditorEventType.Scroll: | ||||
| 					// Not handled | ||||
| 					break; | ||||
| 				default: | ||||
| 					exhaustivenessCheck = event; | ||||
| 					return exhaustivenessCheck; | ||||
| 				} | ||||
| 				return; | ||||
| 			}, | ||||
| 		}; | ||||
|  | ||||
|   | ||||
| @@ -4,11 +4,13 @@ const React = require('react'); | ||||
| const { useMemo, useState, useEffect } = require('react'); | ||||
| const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default; | ||||
|  | ||||
| import { SearchControl, SearchState, EditorSettings } from './types'; | ||||
| import { EditorSettings } from './types'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { BackHandler, TextInput, View, Text, StyleSheet, ViewStyle } from 'react-native'; | ||||
| import { Theme } from '@joplin/lib/themes/type'; | ||||
| import CustomButton from '../CustomButton'; | ||||
| import { SearchState } from '@joplin/editor/types'; | ||||
| import { SearchControl } from './types'; | ||||
|  | ||||
| const buttonSize = 48; | ||||
|  | ||||
| @@ -284,7 +286,7 @@ export const SearchPanel = (props: SearchPanelProps) => { | ||||
| 			themeId={themeId} | ||||
| 			styles={styles} | ||||
| 			iconName="swap-horizontal" | ||||
| 			onPress={control.replaceCurrent} | ||||
| 			onPress={control.replaceNext} | ||||
| 			title={_('Replace')} | ||||
| 		/> | ||||
| 	); | ||||
|   | ||||
| @@ -1,98 +0,0 @@ | ||||
| // Stores information about the current content of the user's selection | ||||
|  | ||||
| export default class SelectionFormatting { | ||||
| 	public bolded = false; | ||||
| 	public italicized = false; | ||||
| 	public inChecklist = false; | ||||
| 	public inCode = false; | ||||
| 	public inUnorderedList = false; | ||||
| 	public inOrderedList = false; | ||||
| 	public inMath = false; | ||||
| 	public inLink = false; | ||||
| 	public spellChecking = false; | ||||
| 	public unspellCheckableRegion = false; | ||||
|  | ||||
| 	// Link data, both fields are null if not in a link. | ||||
| 	public linkData: { linkText?: string; linkURL?: string } = { | ||||
| 		linkText: null, | ||||
| 		linkURL: null, | ||||
| 	}; | ||||
|  | ||||
| 	// If [headerLevel], [listLevel], etc. are zero, then the | ||||
| 	// selection isn't in a header/list | ||||
| 	public headerLevel = 0; | ||||
| 	public listLevel = 0; | ||||
|  | ||||
| 	// Content of the selection | ||||
| 	public selectedText = ''; | ||||
|  | ||||
| 	// List of data properties (for serializing/deseralizing) | ||||
| 	private static propNames: string[] = [ | ||||
| 		'bolded', 'italicized', 'inChecklist', 'inCode', | ||||
| 		'inUnorderedList', 'inOrderedList', 'inMath', | ||||
| 		'inLink', 'linkData', | ||||
|  | ||||
| 		'headerLevel', 'listLevel', | ||||
|  | ||||
| 		'selectedText', | ||||
|  | ||||
| 		'spellChecking', | ||||
| 		'unspellCheckableRegion', | ||||
| 	]; | ||||
|  | ||||
| 	// Returns true iff [this] is equivalent to [other] | ||||
| 	public eq(other: SelectionFormatting): boolean { | ||||
| 		// Cast to Records to allow usage of the indexing ([]) | ||||
| 		// operator. | ||||
| 		const selfAsRec = this as Record<string, any>; | ||||
| 		const otherAsRec = other as Record<string, any>; | ||||
|  | ||||
| 		for (const prop of SelectionFormatting.propNames) { | ||||
| 			if (selfAsRec[prop] !== otherAsRec[prop]) { | ||||
| 				return false; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return true; | ||||
| 	} | ||||
|  | ||||
| 	public static fromJSON(json: string): SelectionFormatting { | ||||
| 		const result = new SelectionFormatting(); | ||||
|  | ||||
| 		// Casting result to a Record<string, any> lets us use | ||||
| 		// the indexing [] operator. | ||||
| 		const resultRecord = result as Record<string, any>; | ||||
| 		const obj = JSON.parse(json) as Record<string, any>; | ||||
|  | ||||
| 		for (const prop of SelectionFormatting.propNames) { | ||||
| 			if (obj[prop] !== undefined) { | ||||
| 				// Type checking! | ||||
| 				if (typeof obj[prop] !== typeof resultRecord[prop]) { | ||||
| 					throw new Error([ | ||||
| 						'Deserialization Error:', | ||||
| 						`${obj[prop]} and ${resultRecord[prop]}`, | ||||
| 						'have different types.', | ||||
| 					].join(' ')); | ||||
| 				} | ||||
|  | ||||
| 				resultRecord[prop] = obj[prop]; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	public toJSON(): string { | ||||
| 		const resultObj: Record<string, any> = {}; | ||||
|  | ||||
| 		// Cast this to a dictionary. This allows us to use | ||||
| 		// the indexing [] operator. | ||||
| 		const selfAsRecord = this as Record<string, any>; | ||||
|  | ||||
| 		for (const prop of SelectionFormatting.propNames) { | ||||
| 			resultObj[prop] = selfAsRecord[prop]; | ||||
| 		} | ||||
|  | ||||
| 		return JSON.stringify(resultObj); | ||||
| 	} | ||||
| } | ||||
| @@ -1,69 +1,53 @@ | ||||
| // Types related to the NoteEditor | ||||
|  | ||||
| import { Theme } from '@joplin/lib/themes/type'; | ||||
| import { CodeMirrorControl } from './CodeMirror/types'; | ||||
|  | ||||
| // Controls for the entire editor (including dialogs) | ||||
| export interface EditorControl extends CodeMirrorControl { | ||||
| 	showLinkDialog(): void; | ||||
| 	hideLinkDialog(): void; | ||||
| 	hideKeyboard(): void; | ||||
| } | ||||
|  | ||||
| export interface EditorSettings { | ||||
| 	// EditorSettings objects are deserialized within WebViews, where | ||||
| 	// [themeStyle(themeId: number)] doesn't work. As such, we need both | ||||
| 	// the [themeId] and [themeData]. | ||||
| 	themeId: number; | ||||
| 	themeData: Theme; | ||||
|  | ||||
| 	katexEnabled: boolean; | ||||
| 	spellcheckEnabled: boolean; | ||||
| 	readOnly: boolean; | ||||
| } | ||||
|  | ||||
| export interface ChangeEvent { | ||||
| 	// New editor content | ||||
| 	value: string; | ||||
| } | ||||
|  | ||||
| export interface UndoRedoDepthChangeEvent { | ||||
| 	undoDepth: number; | ||||
| 	redoDepth: number; | ||||
| } | ||||
|  | ||||
| export interface Selection { | ||||
| 	start: number; | ||||
| 	end: number; | ||||
| } | ||||
|  | ||||
| export interface SelectionChangeEvent { | ||||
| 	selection: Selection; | ||||
| } | ||||
| import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types'; | ||||
|  | ||||
| export interface SearchControl { | ||||
| 	findNext(): void; | ||||
| 	findPrevious(): void; | ||||
| 	replaceCurrent(): void; | ||||
| 	replaceNext(): void; | ||||
| 	replaceAll(): void; | ||||
| 	setSearchState(state: SearchState): void; | ||||
|  | ||||
| 	showSearch(): void; | ||||
| 	hideSearch(): void; | ||||
| 	setSearchState(state: SearchState): void; | ||||
| } | ||||
|  | ||||
| export interface SearchState { | ||||
| 	useRegex: boolean; | ||||
| 	caseSensitive: boolean; | ||||
| // Controls for the entire editor (including dialogs) | ||||
| export interface EditorControl extends EditorBodyControl { | ||||
| 	showLinkDialog(): void; | ||||
| 	hideLinkDialog(): void; | ||||
| 	hideKeyboard(): void; | ||||
|  | ||||
| 	searchText: string; | ||||
| 	replaceText: string; | ||||
| 	dialogVisible: boolean; | ||||
| 	// Additional shortcut commands (equivalent to .execCommand | ||||
| 	// with the corresponding type). | ||||
| 	// This reduces the need for useCallbacks in many cases. | ||||
| 	undo(): void; | ||||
| 	redo(): void; | ||||
|  | ||||
| 	increaseIndent(): void; | ||||
| 	decreaseIndent(): void; | ||||
| 	toggleBolded(): void; | ||||
| 	toggleItalicized(): void; | ||||
| 	toggleCode(): void; | ||||
| 	toggleMath(): void; | ||||
| 	toggleOrderedList(): void; | ||||
| 	toggleUnorderedList(): void; | ||||
| 	toggleTaskList(): void; | ||||
| 	toggleHeaderLevel(level: number): void; | ||||
|  | ||||
| 	scrollSelectionIntoView(): void; | ||||
| 	showLinkDialog(): void; | ||||
| 	hideLinkDialog(): void; | ||||
| 	hideKeyboard(): void; | ||||
|  | ||||
| 	searchControl: SearchControl; | ||||
| } | ||||
|  | ||||
| // Possible types of lists in the editor | ||||
| export enum ListType { | ||||
| 	CheckList, | ||||
| 	OrderedList, | ||||
| 	UnorderedList, | ||||
| export interface EditorSettings extends EditorBodySettings { | ||||
| 	themeId: number; | ||||
| } | ||||
|  | ||||
| export interface SelectionRange { | ||||
| 	start: number; | ||||
| 	end: number; | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService'; | ||||
| import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; | ||||
| import checkPermissions from '../../utils/checkPermissions'; | ||||
| import NoteEditor from '../NoteEditor/NoteEditor'; | ||||
| import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types'; | ||||
|  | ||||
| const FileViewer = require('react-native-file-viewer').default; | ||||
| const React = require('react'); | ||||
| @@ -48,6 +47,7 @@ import Logger from '@joplin/utils/Logger'; | ||||
| import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog'; | ||||
| import { voskEnabled } from '../../services/voiceTyping/vosk'; | ||||
| import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android'; | ||||
| import { ChangeEvent as EditorChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; | ||||
| const urlUtils = require('@joplin/lib/urlUtils'); | ||||
|  | ||||
| // import Vosk from 'react-native-vosk'; | ||||
| @@ -255,7 +255,7 @@ class NoteScreenComponent extends BaseScreenComponent { | ||||
| 		return this.props.useEditorBeta; | ||||
| 	} | ||||
|  | ||||
| 	private onBodyChange(event: ChangeEvent) { | ||||
| 	private onBodyChange(event: EditorChangeEvent) { | ||||
| 		shared.noteComponent_change(this, 'body', event.value); | ||||
| 		this.scheduleSave(); | ||||
| 	} | ||||
|   | ||||
| @@ -14,22 +14,6 @@ import { setImmediate } from 'timers'; | ||||
| // so is removed by jsdom). | ||||
| window.setImmediate = setImmediate; | ||||
|  | ||||
| // Prevents the CodeMirror error "getClientRects is undefined". | ||||
| // See https://github.com/jsdom/jsdom/issues/3002#issue-652790925 | ||||
| document.createRange = () => { | ||||
| 	const range = new Range(); | ||||
| 	range.getBoundingClientRect = jest.fn(); | ||||
| 	range.getClientRects = () => { | ||||
| 		return { | ||||
| 			length: 0, | ||||
| 			item: () => null, | ||||
| 			[Symbol.iterator]: jest.fn(), | ||||
| 		}; | ||||
| 	}; | ||||
|  | ||||
| 	return range; | ||||
| }; | ||||
|  | ||||
|  | ||||
| shimInit({ nodeSqlite: sqlite3 }); | ||||
|  | ||||
|   | ||||
| @@ -15,6 +15,7 @@ const path = require('path'); | ||||
| const localPackages = { | ||||
| 	'@joplin/lib': path.resolve(__dirname, '../lib/'), | ||||
| 	'@joplin/renderer': path.resolve(__dirname, '../renderer/'), | ||||
| 	'@joplin/editor': path.resolve(__dirname, '../editor/'), | ||||
| 	'@joplin/tools': path.resolve(__dirname, '../tools/'), | ||||
| 	'@joplin/utils': path.resolve(__dirname, '../utils/'), | ||||
| 	'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'), | ||||
|   | ||||
| @@ -18,7 +18,8 @@ | ||||
|     "postinstall": "jetify && yarn run build" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@bam.tech/react-native-image-resizer": "3.0.7", | ||||
|     "@bam.tech/react-native-image-resizer": "3.0.5", | ||||
|     "@joplin/editor": "~2.13", | ||||
|     "@joplin/lib": "~2.13", | ||||
|     "@joplin/react-native-alarm-notification": "~2.13", | ||||
|     "@joplin/react-native-saf-x": "~2.13", | ||||
| @@ -86,19 +87,6 @@ | ||||
|     "@babel/core": "7.20.2", | ||||
|     "@babel/preset-env": "7.20.2", | ||||
|     "@babel/runtime": "7.20.0", | ||||
|     "@codemirror/commands": "6.2.2", | ||||
|     "@codemirror/lang-cpp": "6.0.2", | ||||
|     "@codemirror/lang-html": "6.4.3", | ||||
|     "@codemirror/lang-java": "6.0.1", | ||||
|     "@codemirror/lang-javascript": "6.1.5", | ||||
|     "@codemirror/lang-markdown": "6.1.0", | ||||
|     "@codemirror/lang-php": "6.0.1", | ||||
|     "@codemirror/lang-rust": "6.0.1", | ||||
|     "@codemirror/language": "6.6.0", | ||||
|     "@codemirror/legacy-modes": "6.3.2", | ||||
|     "@codemirror/search": "6.3.0", | ||||
|     "@codemirror/state": "6.2.0", | ||||
|     "@codemirror/view": "6.9.3", | ||||
|     "@joplin/tools": "~2.13", | ||||
|     "@lezer/highlight": "1.1.4", | ||||
|     "@testing-library/jest-native": "5.4.3", | ||||
|   | ||||
| @@ -0,0 +1,111 @@ | ||||
| import CodeMirror5Emulation from './CodeMirror5Emulation'; | ||||
| import { EditorView } from '@codemirror/view'; | ||||
|  | ||||
| const makeCodeMirrorEmulation = (initialDocText: string) => { | ||||
| 	const editorView = new EditorView({ | ||||
| 		doc: initialDocText, | ||||
| 	}); | ||||
| 	return new CodeMirror5Emulation(editorView, ()=>{}); | ||||
| }; | ||||
|  | ||||
| describe('CodeMirror5Emulation', () => { | ||||
| 	it('getSearchCursor should support searching for strings', () => { | ||||
| 		const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.'); | ||||
|  | ||||
| 		// Should find two matches for "test" | ||||
| 		// Note that the CodeMirror documentation specifies that a search cursor | ||||
| 		// should return a boolean when calling findNext/findPrevious. However, | ||||
| 		// the codemirror-vim adapter returns just a truthy/falsy value. | ||||
| 		const testCursor = codeMirror.getSearchCursor('test'); | ||||
| 		expect(testCursor.findNext()).toBeTruthy(); | ||||
| 		expect(testCursor.findNext()).toBeTruthy(); | ||||
|  | ||||
| 		// Replace the second match | ||||
| 		testCursor.replace('passing test'); | ||||
| 		expect(codeMirror.getValue()).toBe('testing --- this is a passing test.'); | ||||
|  | ||||
| 		// Should also be able to find previous matches | ||||
| 		expect(testCursor.findPrevious()).toBeTruthy(); | ||||
|  | ||||
| 		// Should return a falsy value when attempting to search past the end of | ||||
| 		// the document. | ||||
| 		expect(testCursor.findPrevious()).toBeFalsy(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should fire update/change events on change', async () => { | ||||
| 		const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.'); | ||||
|  | ||||
| 		const updateCallback = jest.fn(); | ||||
| 		const changeCallback = jest.fn(); | ||||
| 		codeMirror.on('update', updateCallback); | ||||
| 		codeMirror.on('change', changeCallback); | ||||
|  | ||||
| 		expect(updateCallback).not.toHaveBeenCalled(); | ||||
| 		expect(changeCallback).not.toHaveBeenCalled(); | ||||
|  | ||||
| 		jest.useFakeTimers(); | ||||
|  | ||||
| 		// Inserting text should trigger the update and change events | ||||
| 		codeMirror.editor.dispatch({ | ||||
| 			changes: { from: 0, to: 1, insert: 'Test: ' }, | ||||
| 		}); | ||||
|  | ||||
| 		// Advance timers -- there may be a delay between the CM 6 event | ||||
| 		// and the dispatched CM 5 event. | ||||
| 		await jest.advanceTimersByTimeAsync(100); | ||||
|  | ||||
| 		expect(updateCallback).toHaveBeenCalled(); | ||||
| 		expect(changeCallback).toHaveBeenCalled(); | ||||
|  | ||||
| 		// The change callback should be given two arguments: | ||||
| 		// - the CodeMirror emulation object | ||||
| 		// - a description of the changes | ||||
| 		expect(changeCallback.mock.lastCall[0]).toBe(codeMirror); | ||||
| 		expect(changeCallback.mock.lastCall[1]).toMatchObject({ | ||||
| 			from: { line: 0, ch: 0 }, | ||||
| 			to: { line: 0, ch: 1 }, | ||||
|  | ||||
| 			// Arrays of lines | ||||
| 			text: ['Test: '], | ||||
| 			removed: ['t'], | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	it('defineOption should fire the option\'s update callback on change', () => { | ||||
| 		const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2'); | ||||
|  | ||||
| 		const onOptionUpdate = jest.fn(); | ||||
| 		codeMirror.defineOption('an-option!', 'test', onOptionUpdate); | ||||
|  | ||||
| 		const onOtherOptionUpdate = jest.fn(); | ||||
| 		codeMirror.defineOption('an-option 2', 1, onOtherOptionUpdate); | ||||
|  | ||||
|  | ||||
| 		// onUpdate should be called once initially | ||||
| 		expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1); | ||||
| 		expect(onOptionUpdate).toHaveBeenCalledTimes(1); | ||||
| 		expect(onOptionUpdate).toHaveBeenLastCalledWith( | ||||
| 			codeMirror, | ||||
|  | ||||
| 			// default value -- the new value | ||||
| 			'test', | ||||
|  | ||||
| 			// the original value (none, so given CodeMirror.Init) | ||||
| 			codeMirror.Init, | ||||
| 		); | ||||
|  | ||||
| 		// onUpdate should be called each time the option changes | ||||
| 		codeMirror.setOption('an-option!', 'test 2'); | ||||
| 		expect(onOptionUpdate).toHaveBeenCalledTimes(2); | ||||
| 		expect(onOptionUpdate).toHaveBeenLastCalledWith( | ||||
| 			codeMirror, 'test 2', 'test', | ||||
| 		); | ||||
|  | ||||
| 		codeMirror.setOption('an-option!', 'test...'); | ||||
| 		expect(onOptionUpdate).toHaveBeenCalledTimes(3); | ||||
|  | ||||
| 		// The other update callback should not have been triggered | ||||
| 		// additional times if its option hasn't updated. | ||||
| 		expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1); | ||||
| 	}); | ||||
| }); | ||||
| @@ -0,0 +1,410 @@ | ||||
| import { EditorView, ViewPlugin, ViewUpdate, showPanel } from '@codemirror/view'; | ||||
| import { Extension, Text, Transaction } from '@codemirror/state'; | ||||
| import getScrollFraction from '../getScrollFraction'; | ||||
| import { CodeMirror as BaseCodeMirror5Emulation, Vim } from '@replit/codemirror-vim'; | ||||
| import { LogMessageCallback } from '../../types'; | ||||
| import editorCommands from '../editorCommands/editorCommands'; | ||||
| import { StateEffect } from '@codemirror/state'; | ||||
| import { StreamParser } from '@codemirror/language'; | ||||
| import Decorator, { LineWidgetOptions } from './Decorator'; | ||||
| const { pregQuote } = require('@joplin/lib/string-utils-common'); | ||||
|  | ||||
|  | ||||
| type CodeMirror5Command = (codeMirror: CodeMirror5Emulation)=> void; | ||||
|  | ||||
| type EditorEventCallback = (editor: CodeMirror5Emulation, ...args: any[])=> void; | ||||
| type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void; | ||||
|  | ||||
| interface CodeMirror5OptionRecord { | ||||
| 	onUpdate: OptionUpdateCallback; | ||||
| 	value: any; | ||||
| } | ||||
|  | ||||
| interface DocumentPosition { | ||||
| 	line: number; | ||||
| 	ch: number; | ||||
| } | ||||
|  | ||||
| const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => { | ||||
| 	const line = doc.lineAt(pos); | ||||
| 	return { | ||||
| 		// CM 5 uses 0-based line numbering | ||||
| 		line: line.number - 1, | ||||
| 		ch: pos - line.from, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation { | ||||
| 	private _events: Record<string, EditorEventCallback[]> = {}; | ||||
| 	private _options: Record<string, CodeMirror5OptionRecord> = Object.create(null); | ||||
| 	private _decorator: Decorator; | ||||
| 	private _decoratorExtension: Extension; | ||||
|  | ||||
| 	// Used by some plugins to store state. | ||||
| 	public state: Record<string, any> = Object.create(null); | ||||
|  | ||||
| 	public Vim = Vim; | ||||
|  | ||||
| 	// Passed as initial state to plugins | ||||
| 	public Init = { toString: () => 'CodeMirror.Init' }; | ||||
|  | ||||
| 	public constructor( | ||||
| 		public editor: EditorView, | ||||
| 		private logMessage: LogMessageCallback, | ||||
| 	) { | ||||
| 		super(editor); | ||||
|  | ||||
| 		const { decorator, extension: decoratorExtension } = Decorator.create(editor); | ||||
| 		this._decorator = decorator; | ||||
| 		this._decoratorExtension = decoratorExtension; | ||||
|  | ||||
| 		editor.dispatch({ | ||||
| 			effects: StateEffect.appendConfig.of(this.makeCM6Extensions()), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	private makeCM6Extensions() { | ||||
| 		const cm5 = this; | ||||
| 		const editor = this.editor; | ||||
|  | ||||
| 		return [ | ||||
| 			// Fires events | ||||
| 			EditorView.domEventHandlers({ | ||||
| 				scroll: () => CodeMirror5Emulation.signal(this, 'scroll'), | ||||
| 				focus: () => CodeMirror5Emulation.signal(this, 'focus'), | ||||
| 				blur: () => CodeMirror5Emulation.signal(this, 'blur'), | ||||
| 				mousedown: event => CodeMirror5Emulation.signal(this, 'mousedown', event), | ||||
| 			}), | ||||
| 			ViewPlugin.fromClass(class { | ||||
| 				public update(update: ViewUpdate) { | ||||
| 					try { | ||||
| 						if (update.viewportChanged) { | ||||
| 							CodeMirror5Emulation.signal( | ||||
| 								cm5, | ||||
| 								'viewportChange', | ||||
| 								editor.viewport.from, | ||||
| 								editor.viewport.to, | ||||
| 							); | ||||
| 						} | ||||
|  | ||||
| 						if (update.docChanged) { | ||||
| 							cm5.fireChangeEvents(update); | ||||
| 							cm5.onChange(update); | ||||
| 						} | ||||
|  | ||||
| 						if (update.selectionSet) { | ||||
| 							cm5.onSelectionChange(); | ||||
| 						} | ||||
|  | ||||
| 						CodeMirror5Emulation.signal(cm5, 'update'); | ||||
| 					// Catch the error -- otherwise, CodeMirror will de-register the update listener. | ||||
| 					} catch (error) { | ||||
| 						cm5.logMessage(`Error dispatching update: ${error}`); | ||||
| 					} | ||||
| 				} | ||||
| 			}), | ||||
|  | ||||
| 			// Decorations | ||||
| 			this._decoratorExtension, | ||||
|  | ||||
| 			// Some plugins rely on a CodeMirror-measure element | ||||
| 			// to store temporary content. | ||||
| 			showPanel.of(() => { | ||||
| 				const dom = document.createElement('div'); | ||||
| 				dom.classList.add('CodeMirror-measure'); | ||||
| 				return { dom }; | ||||
| 			}), | ||||
|  | ||||
| 			// Note: We can allow legacy CM5 CSS to apply to the editor | ||||
| 			// with a line similar to the following: | ||||
| 			//    EditorView.editorAttributes.of({ class: 'CodeMirror' }), | ||||
| 			// Many of these styles, however, don't work well with CodeMirror 6. | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| 	private isEventHandledBySuperclass(eventName: string) { | ||||
| 		return ['beforeSelectionChange'].includes(eventName); | ||||
| 	} | ||||
|  | ||||
| 	public on(eventName: string, callback: EditorEventCallback) { | ||||
| 		if (this.isEventHandledBySuperclass(eventName)) { | ||||
| 			return super.on(eventName, callback); | ||||
| 		} | ||||
| 		this._events[eventName] ??= []; | ||||
| 		this._events[eventName].push(callback); | ||||
| 	} | ||||
|  | ||||
| 	public off(eventName: string, callback: EditorEventCallback) { | ||||
| 		if (!(eventName in this._events)) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this._events[eventName] = this._events[eventName].filter( | ||||
| 			otherCallback => otherCallback !== callback, | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	public static signal(target: CodeMirror5Emulation, eventName: string, ...args: any[]) { | ||||
| 		const listeners = target._events[eventName] ?? []; | ||||
|  | ||||
| 		for (const listener of listeners) { | ||||
| 			listener(target, ...args); | ||||
| 		} | ||||
|  | ||||
| 		super.signal(target, eventName, ...args); | ||||
| 	} | ||||
|  | ||||
| 	private fireChangeEvents(update: ViewUpdate) { | ||||
| 		type ChangeRecord = { | ||||
| 			from: DocumentPosition; | ||||
| 			to: DocumentPosition; | ||||
| 			text: string[]; | ||||
| 			removed: string[]; | ||||
| 			transaction: Transaction; | ||||
| 		}; | ||||
| 		const changes: ChangeRecord[] = []; | ||||
| 		const origDoc = update.startState.doc; | ||||
|  | ||||
| 		for (const transaction of update.transactions) { | ||||
| 			transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted: Text) => { | ||||
| 				changes.push({ | ||||
| 					from: documentPositionFromPos(origDoc, fromA), | ||||
| 					to: documentPositionFromPos(origDoc, toA), | ||||
| 					text: inserted.sliceString(0).split('\n'), | ||||
| 					removed: origDoc.sliceString(fromA, toA).split('\n'), | ||||
| 					transaction, | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Delay firing events -- event listeners may try to create transactions. | ||||
| 		// (this is done by the rich markdown plugin). | ||||
| 		setTimeout(() => { | ||||
| 			for (const change of changes) { | ||||
| 				CodeMirror5Emulation.signal(this, 'change', change); | ||||
|  | ||||
| 				// If triggered by a user, also send the inputRead event | ||||
| 				if (change.transaction.isUserEvent('input')) { | ||||
| 					CodeMirror5Emulation.signal(this, 'inputRead', change); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			CodeMirror5Emulation.signal(this, 'changes', changes); | ||||
| 		}, 0); | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	// codemirror-vim's adapter doesn't match the CM5 docs -- wrap it. | ||||
| 	public getCursor(mode?: 'head' | 'anchor' | 'from' | 'to'| 'start' | 'end') { | ||||
| 		if (mode === 'from') { | ||||
| 			mode = 'start'; | ||||
| 		} | ||||
| 		if (mode === 'to') { | ||||
| 			mode = 'end'; | ||||
| 		} | ||||
|  | ||||
| 		return super.getCursor(mode); | ||||
| 	} | ||||
|  | ||||
| 	public override getSearchCursor(query: RegExp|string, pos?: DocumentPosition|null|0) { | ||||
| 		// The superclass CodeMirror adapter only supports regular expression | ||||
| 		// arguments. | ||||
| 		if (typeof query === 'string') { | ||||
| 			query = new RegExp(pregQuote(query)); | ||||
| 		} | ||||
| 		return super.getSearchCursor(query, pos || { line: 0, ch: 0 }); | ||||
| 	} | ||||
|  | ||||
| 	public lineAtHeight(height: number, _mode?: 'local') { | ||||
| 		const lineInfo = this.editor.lineBlockAtHeight(height); | ||||
|  | ||||
| 		// - 1: Convert to zero-based. | ||||
| 		const lineNumber = this.editor.state.doc.lineAt(lineInfo.to).number - 1; | ||||
| 		return lineNumber; | ||||
| 	} | ||||
|  | ||||
| 	public heightAtLine(lineNumber: number, mode?: 'local') { | ||||
| 		// CodeMirror 5 uses 0-based line numbers. CM6 uses 1-based | ||||
| 		// line numbers. | ||||
| 		const doc = this.editor.state.doc; | ||||
| 		const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines)); | ||||
| 		const lineBlock = this.editor.lineBlockAt(lineInfo.from); | ||||
|  | ||||
| 		const height = lineBlock.top; | ||||
| 		if (mode === 'local') { | ||||
| 			const editorTop = this.editor.lineBlockAt(0).top; | ||||
| 			return height - editorTop; | ||||
| 		} else { | ||||
| 			return height; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public lineInfo(lineNumber: number) { | ||||
| 		const line = this.editor.state.doc.line(lineNumber + 1); | ||||
|  | ||||
| 		const result = { | ||||
| 			line: lineNumber, | ||||
|  | ||||
| 			// Note: In CM5, a line handle is not just a line number | ||||
| 			handle: lineNumber, | ||||
|  | ||||
| 			text: line.text, | ||||
| 			gutterMarkers: [] as any[], | ||||
| 			textClass: ['cm-line', ...this._decorator.getLineClasses(lineNumber)], | ||||
| 			bgClass: '', | ||||
| 			wrapClass: '', | ||||
| 			widgets: this._decorator.getLineWidgets(lineNumber), | ||||
| 		}; | ||||
|  | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	public getStateAfter(_line: number) { | ||||
| 		// TODO: Should return parser state. Returning an empty object | ||||
| 		//       allows some plugins to run without crashing, however. | ||||
| 		return {}; | ||||
| 	} | ||||
|  | ||||
| 	public getScrollPercent() { | ||||
| 		return getScrollFraction(this.editor); | ||||
| 	} | ||||
|  | ||||
| 	public defineExtension(name: string, value: any) { | ||||
| 		(CodeMirror5Emulation.prototype as any)[name] ??= value; | ||||
| 	} | ||||
|  | ||||
| 	public defineOption(name: string, defaultValue: any, onUpdate: OptionUpdateCallback) { | ||||
| 		this._options[name] = { | ||||
| 			value: defaultValue, | ||||
| 			onUpdate, | ||||
| 		}; | ||||
| 		onUpdate(this, defaultValue, this.Init); | ||||
| 	} | ||||
|  | ||||
| 	// Override codemirror-vim's setOption to allow user-defined options | ||||
| 	public override setOption(name: string, value: any) { | ||||
| 		if (name in this._options) { | ||||
| 			const oldValue = this._options[name].value; | ||||
| 			this._options[name].value = value; | ||||
| 			this._options[name].onUpdate(this, value, oldValue); | ||||
| 		} else { | ||||
| 			super.setOption(name, value); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public override getOption(name: string): any { | ||||
| 		if (name in this._options) { | ||||
| 			return this._options[name].value; | ||||
| 		} else { | ||||
| 			return super.getOption(name); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// codemirror-vim's API doesn't match the API docs here -- it expects addOverlay | ||||
| 	// to return a SearchQuery. As such, this override returns "any". | ||||
| 	public override addOverlay<State>(modeObject: StreamParser<State>|{ query: RegExp }): any { | ||||
| 		if ('query' in modeObject) { | ||||
| 			return super.addOverlay(modeObject); | ||||
| 		} | ||||
|  | ||||
| 		this._decorator.addOverlay(modeObject); | ||||
| 	} | ||||
|  | ||||
| 	public addLineClass(lineNumber: number, where: string, className: string) { | ||||
| 		this._decorator.addLineClass(lineNumber, where, className); | ||||
| 	} | ||||
|  | ||||
| 	public removeLineClass(lineNumber: number, where: string, className: string) { | ||||
| 		this._decorator.removeLineClass(lineNumber, where, className); | ||||
| 	} | ||||
|  | ||||
| 	public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions) { | ||||
| 		this._decorator.addLineWidget(lineNumber, node, options); | ||||
| 	} | ||||
|  | ||||
| 	// TODO: Currently copied from useCursorUtils.ts. | ||||
| 	// TODO: Remove the duplicate code when CodeMirror 5 is eventually removed. | ||||
| 	public wrapSelections(string1: string, string2: string) { | ||||
| 		const selectedStrings = this.getSelections(); | ||||
|  | ||||
| 		// Batches the insert operations, if this wasn't done the inserts | ||||
| 		// could potentially overwrite one another | ||||
| 		this.operation(() => { | ||||
| 			for (let i = 0; i < selectedStrings.length; i++) { | ||||
| 				const selected = selectedStrings[i]; | ||||
|  | ||||
| 				// Remove white space on either side of selection | ||||
| 				const start = selected.search(/[^\s]/); | ||||
| 				const end = selected.search(/[^\s](?=[\s]*$)/); | ||||
| 				const core = selected.substring(start, end - start + 1); | ||||
|  | ||||
| 				// If selection can be toggled do that | ||||
| 				if (core.startsWith(string1) && core.endsWith(string2)) { | ||||
| 					const inside = core.substring(string1.length, core.length - string1.length - string2.length); | ||||
| 					selectedStrings[i] = selected.substring(0, start) + inside + selected.substring(end + 1); | ||||
| 				} else { | ||||
| 					selectedStrings[i] = selected.substring(0, start) + string1 + core + string2 + selected.substring(end + 1); | ||||
| 				} | ||||
| 			} | ||||
| 			this.replaceSelections(selectedStrings); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public static commands = (() => { | ||||
| 		const commands: Record<string, CodeMirror5Command> = { | ||||
| 			...BaseCodeMirror5Emulation.commands, | ||||
| 		}; | ||||
|  | ||||
| 		for (const commandName in editorCommands) { | ||||
| 			const command = editorCommands[commandName as keyof typeof editorCommands]; | ||||
|  | ||||
| 			commands[commandName] = (codeMirror: CodeMirror5Emulation) => command(codeMirror.editor); | ||||
| 		} | ||||
|  | ||||
| 		// as any: Required to properly extend the base class -- without this, | ||||
| 		// the commands dictionary isn't known (by TypeScript) to have the same | ||||
| 		// properties as the commands dictionary in the parent class. | ||||
| 		return commands as any; | ||||
| 	})(); | ||||
|  | ||||
| 	public commands = CodeMirror5Emulation.commands; | ||||
|  | ||||
| 	private joplinCommandToCodeMirrorCommand(commandName: string): string|null { | ||||
| 		const match = /^editor\.(.*)$/g.exec(commandName); | ||||
|  | ||||
| 		if (!match || !(match[1] in CodeMirror5Emulation.commands)) { | ||||
| 			return null; | ||||
| 		} | ||||
|  | ||||
| 		return match[1] as string; | ||||
| 	} | ||||
|  | ||||
| 	public supportsJoplinCommand(commandName: string): boolean { | ||||
| 		return this.joplinCommandToCodeMirrorCommand(commandName) in CodeMirror5Emulation.commands; | ||||
| 	} | ||||
|  | ||||
| 	public execJoplinCommand(joplinCommandName: string) { | ||||
| 		const commandName = this.joplinCommandToCodeMirrorCommand(joplinCommandName); | ||||
|  | ||||
| 		if (commandName === null) { | ||||
| 			this.logMessage(`Unsupported Joplin command, ${joplinCommandName}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this.execCommand(commandName); | ||||
| 	} | ||||
|  | ||||
| 	public commandExists(commandName: string) { | ||||
| 		return commandName in CodeMirror5Emulation.commands; | ||||
| 	} | ||||
|  | ||||
| 	public execCommand(name: string) { | ||||
| 		if (!this.commandExists(name)) { | ||||
| 			this.logMessage(`Unsupported CodeMirror command, ${name}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		CodeMirror5Emulation.commands[name as (keyof typeof CodeMirror5Emulation.commands)](this); | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										385
									
								
								packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										385
									
								
								packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,385 @@ | ||||
|  | ||||
| // Handles adding decorations to the CodeMirror editor -- converts CodeMirror5-style calls | ||||
| // to input accepted by CodeMirror 6 | ||||
|  | ||||
| import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view'; | ||||
| import { ChangeDesc, Extension, Range, RangeSetBuilder, StateEffect, StateField, Transaction } from '@codemirror/state'; | ||||
| import { StreamParser, StringStream, indentUnit } from '@codemirror/language'; | ||||
|  | ||||
| interface DecorationRange { | ||||
| 	from: number; | ||||
| 	to: number; | ||||
| } | ||||
|  | ||||
| const mapRangeConfig = { | ||||
| 	// Updates a range based on some change to the document | ||||
| 	map: <T extends DecorationRange> (range: T, change: ChangeDesc): T => { | ||||
| 		const from = change.mapPos(range.from); | ||||
| 		const to = change.mapPos(range.to); | ||||
| 		return { | ||||
| 			...range, | ||||
| 			from: Math.min(from, to), | ||||
| 			to: Math.max(from, to), | ||||
| 		}; | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| interface LineCssDecorationSpec extends DecorationRange { | ||||
| 	cssClass: string; | ||||
| } | ||||
|  | ||||
| const addLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); | ||||
| const removeLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); | ||||
| const addMarkDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); | ||||
| // TODO: Support removing mark decorations | ||||
| //     const removeMarkDecorationEffect = StateEffect.define<LineDecorationSpec>(mapRangeConfig); | ||||
|  | ||||
| export interface LineWidgetOptions { | ||||
| 	className?: string; | ||||
| 	above?: boolean; | ||||
| } | ||||
|  | ||||
| interface LineWidgetDecorationSpec extends DecorationRange { | ||||
| 	element: HTMLElement; | ||||
| 	options: LineWidgetOptions; | ||||
| } | ||||
| const addLineWidgetEffect = StateEffect.define<LineWidgetDecorationSpec>(mapRangeConfig); | ||||
| const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>(); | ||||
|  | ||||
|  | ||||
| class WidgetDecorationWrapper extends WidgetType { | ||||
| 	public constructor( | ||||
| 		public readonly element: HTMLElement, | ||||
| 		public readonly options: LineWidgetOptions, | ||||
| 	) { | ||||
| 		super(); | ||||
| 	} | ||||
|  | ||||
| 	public override toDOM() { | ||||
| 		const container = document.createElement('div'); | ||||
| 		this.element.remove(); | ||||
| 		container.appendChild(this.element); | ||||
|  | ||||
| 		if (this.options.className) { | ||||
| 			container.classList.add(this.options.className); | ||||
| 		} | ||||
|  | ||||
| 		return container; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| interface LineWidgetControl { | ||||
| 	node: HTMLElement; | ||||
| 	clear(): void; | ||||
| 	changed(): void; | ||||
| 	className?: string; | ||||
| } | ||||
|  | ||||
| export default class Decorator { | ||||
| 	private _extension: Extension; | ||||
| 	private _effectDecorations: DecorationSet = Decoration.none; | ||||
|  | ||||
| 	private constructor(private editor: EditorView) { | ||||
| 		const decorator = this; | ||||
| 		this._extension = [ | ||||
| 			// Overlay decorations -- recreate all decorations when the editor changes | ||||
| 			// (overlay decorations require parsing the document and may change output | ||||
| 			// when the editor/view changes.) | ||||
| 			ViewPlugin.fromClass(class { | ||||
| 				public decorations: DecorationSet; | ||||
|  | ||||
| 				public constructor(view: EditorView) { | ||||
| 					this.decorations = decorator.createOverlayDecorations(view); | ||||
| 				} | ||||
|  | ||||
| 				public update(update: ViewUpdate) { | ||||
| 					if (update.viewportChanged || update.docChanged) { | ||||
| 						this.decorations = decorator.createOverlayDecorations(update.view); | ||||
| 					} | ||||
| 				} | ||||
| 			}, { | ||||
| 				decorations: v => v.decorations, | ||||
| 			}), | ||||
|  | ||||
| 			// Other decorations based on effects. See the decoration examples: https://codemirror.net/examples/decoration/ | ||||
| 			// Note that EditorView.decorations.from is required for block widgets. | ||||
| 			StateField.define<DecorationSet>({ | ||||
| 				create: () => Decoration.none, | ||||
| 				update: (_, viewUpdate) => decorator.updateEffectDecorations([viewUpdate]), | ||||
| 				provide: field => EditorView.decorations.from(field), | ||||
| 			}), | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| 	public static create(editor: EditorView) { | ||||
| 		const decorator = new Decorator(editor); | ||||
|  | ||||
| 		return { decorator, extension: decorator._extension }; | ||||
| 	} | ||||
|  | ||||
| 	private _decorationCache: Record<string, Decoration> = Object.create(null); | ||||
| 	private _overlays: (StreamParser<any>)[] = []; | ||||
|  | ||||
| 	private classNameToCssDecoration(className: string, isLineDecoration: boolean) { | ||||
| 		let decoration; | ||||
|  | ||||
| 		if (className in this._decorationCache) { | ||||
| 			decoration = this._decorationCache[className]; | ||||
| 		} else { | ||||
| 			const attributes = { class: className }; | ||||
|  | ||||
| 			if (isLineDecoration) { | ||||
| 				decoration = Decoration.line({ attributes }); | ||||
| 			} else { | ||||
| 				decoration = Decoration.mark({ attributes }); | ||||
| 			} | ||||
|  | ||||
| 			this._decorationCache[className] = decoration; | ||||
| 		} | ||||
|  | ||||
| 		return decoration; | ||||
| 	} | ||||
|  | ||||
| 	private updateEffectDecorations(transactions: Transaction[]) { | ||||
| 		let decorations = this._effectDecorations; | ||||
|  | ||||
| 		// Update decoration positions | ||||
| 		for (const transaction of transactions) { | ||||
| 			decorations = decorations.map(transaction.changes); | ||||
|  | ||||
| 			// Add or remove decorations | ||||
| 			for (const effect of transaction.effects) { | ||||
| 				const isMarkDecoration = effect.is(addMarkDecorationEffect); | ||||
| 				const isLineDecoration = effect.is(addLineDecorationEffect); | ||||
| 				if (isMarkDecoration || isLineDecoration) { | ||||
| 					const decoration = this.classNameToCssDecoration( | ||||
| 						effect.value.cssClass, isLineDecoration, | ||||
| 					); | ||||
|  | ||||
| 					const value = effect.value; | ||||
| 					const from = effect.value.from; | ||||
|  | ||||
| 					// Line decorations are specified to have a size-zero range. | ||||
| 					const to = isLineDecoration ? from : value.to; | ||||
|  | ||||
| 					decorations = decorations.update({ | ||||
| 						add: [decoration.range(from, to)], | ||||
| 					}); | ||||
| 				} else if (effect.is(removeLineDecorationEffect)) { | ||||
| 					const doc = transaction.state.doc; | ||||
| 					const targetFrom = doc.lineAt(effect.value.from).from; | ||||
| 					const targetTo = doc.lineAt(effect.value.to).to; | ||||
|  | ||||
| 					const targetDecoration = this.classNameToCssDecoration(effect.value.cssClass, true); | ||||
|  | ||||
| 					decorations = decorations.update({ | ||||
| 						// Returns true only for decorations that should be kept. | ||||
| 						filter: (from, to, value) => { | ||||
| 							if (from >= targetFrom && to <= targetTo && value.eq(targetDecoration)) { | ||||
| 								return false; | ||||
| 							} | ||||
|  | ||||
| 							return true; | ||||
| 						}, | ||||
| 					}); | ||||
| 				} else if (effect.is(addLineWidgetEffect)) { | ||||
| 					const options = effect.value.options; | ||||
| 					const decoration = Decoration.widget({ | ||||
| 						widget: new WidgetDecorationWrapper(effect.value.element, options), | ||||
| 						side: options.above ? -1 : 1, | ||||
| 						block: true, | ||||
| 					}); | ||||
|  | ||||
| 					decorations = decorations.update({ | ||||
| 						add: [decoration.range(options.above ? effect.value.from : effect.value.to)], | ||||
| 					}); | ||||
| 				} else if (effect.is(removeLineWidgetEffect)) { | ||||
| 					decorations = decorations.update({ | ||||
| 						// Returns true only for decorations that should be kept. | ||||
| 						filter: (_from, _to, value) => { | ||||
| 							return value.spec.widget?.element !== effect.value.element; | ||||
| 						}, | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		this._effectDecorations = decorations; | ||||
| 		return decorations; | ||||
| 	} | ||||
|  | ||||
| 	private createOverlayDecorations(view: EditorView): DecorationSet { | ||||
| 		const makeDecoration = ( | ||||
| 			tokenName: string, start: number, stop: number, | ||||
| 		) => { | ||||
| 			const isLineDecoration = tokenName.startsWith('line-'); | ||||
|  | ||||
| 			// CM5 prefixes class names with cm- | ||||
| 			tokenName = `cm-${tokenName}`; | ||||
|  | ||||
| 			const decoration = this.classNameToCssDecoration(tokenName, isLineDecoration); | ||||
| 			return decoration.range(start, stop); | ||||
| 		}; | ||||
|  | ||||
| 		const indentSize = view.state.facet(indentUnit).length; | ||||
| 		const newDecorations: Range<Decoration>[] = []; | ||||
|  | ||||
| 		for (const overlay of this._overlays) { | ||||
| 			const state = overlay.startState?.(indentSize) ?? {}; | ||||
|  | ||||
| 			for (const { from, to } of view.visibleRanges) { | ||||
| 				const fromLine = view.state.doc.lineAt(from); | ||||
| 				const toLine = view.state.doc.lineAt(to); | ||||
|  | ||||
| 				const fromLineNumber = fromLine.number; | ||||
| 				const toLineNumber = toLine.number; | ||||
|  | ||||
| 				for (let i = fromLineNumber; i <= toLineNumber; i++) { | ||||
| 					const line = view.state.doc.line(i); | ||||
|  | ||||
| 					const reader = new StringStream( | ||||
| 						line.text, | ||||
| 						view.state.tabSize, | ||||
| 						indentSize, | ||||
| 					); | ||||
| 					let lastPos = 0; | ||||
|  | ||||
| 					(reader as any).baseToken ??= (): null => null; | ||||
|  | ||||
| 					while (!reader.eol()) { | ||||
| 						const token = overlay.token(reader, state); | ||||
|  | ||||
| 						if (token) { | ||||
| 							for (const className of token.split(/\s+/)) { | ||||
| 								if (className.startsWith('line-')) { | ||||
| 									newDecorations.push(makeDecoration(className, line.from, line.from)); | ||||
| 								} else { | ||||
| 									const from = lastPos + line.from; | ||||
| 									const to = reader.pos + line.from; | ||||
| 									newDecorations.push(makeDecoration(className, from, to)); | ||||
| 								} | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						if (reader.pos === lastPos) { | ||||
| 							throw new Error( | ||||
| 								'Mark decoration position did not increase -- overlays must advance with each call to .token()', | ||||
| 							); | ||||
| 						} | ||||
|  | ||||
| 						lastPos = reader.pos; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Required by CodeMirror: | ||||
| 		// Should be sorted by from position, then by length. | ||||
| 		newDecorations.sort((a, b) => { | ||||
| 			if (a.from !== b.from) { | ||||
| 				return a.from - b.from; | ||||
| 			} | ||||
|  | ||||
| 			return a.to - b.to; | ||||
| 		}); | ||||
|  | ||||
| 		// Per the documentation, new tokens should be added in | ||||
| 		// increasing order. | ||||
| 		const decorations = new RangeSetBuilder<Decoration>(); | ||||
|  | ||||
| 		for (const decoration of newDecorations) { | ||||
| 			decorations.add(decoration.from, decoration.to, decoration.value); | ||||
| 		} | ||||
|  | ||||
| 		return decorations.finish(); | ||||
| 	} | ||||
|  | ||||
| 	public addOverlay<State>(modeObject: StreamParser<State>) { | ||||
| 		this._overlays.push(modeObject); | ||||
| 	} | ||||
|  | ||||
| 	private addRemoveLineClass(lineNumber: number, className: string, add: boolean) { | ||||
| 		// + 1: Convert from zero-indexed to one-indexed | ||||
| 		const line = this.editor.state.doc.line(lineNumber + 1); | ||||
|  | ||||
| 		const effect = add ? addLineDecorationEffect : removeLineDecorationEffect; | ||||
| 		this.editor.dispatch({ | ||||
| 			effects: effect.of({ | ||||
| 				cssClass: className, | ||||
| 				from: line.from, | ||||
| 				to: line.to, | ||||
| 			}), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public addLineClass(lineNumber: number, _where: string, className: string) { | ||||
| 		this.addRemoveLineClass(lineNumber, className, true); | ||||
| 	} | ||||
|  | ||||
| 	public removeLineClass(lineNumber: number, _where: string, className: string) { | ||||
| 		this.addRemoveLineClass(lineNumber, className, false); | ||||
| 	} | ||||
|  | ||||
| 	public getLineClasses(lineNumber: number) { | ||||
| 		const line = this.editor.state.doc.line(lineNumber + 1); | ||||
| 		const lineClasses: string[] = []; | ||||
|  | ||||
| 		this._effectDecorations.between(line.from, line.to, (from, to, decoration) => { | ||||
| 			if (from === line.from && to === line.to) { | ||||
| 				const className = decoration.spec?.class; | ||||
| 				if (typeof className === 'string') { | ||||
| 					lineClasses.push(className); | ||||
| 				} | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		return lineClasses; | ||||
| 	} | ||||
|  | ||||
| 	private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl { | ||||
| 		return { | ||||
| 			node, | ||||
| 			clear: () => { | ||||
| 				this.editor.dispatch({ | ||||
| 					effects: removeLineWidgetEffect.of({ element: node }), | ||||
| 				}); | ||||
| 			}, | ||||
| 			changed: () => { | ||||
| 				this.editor.requestMeasure(); | ||||
| 			}, | ||||
| 			className: options.className, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	public getLineWidgets(lineNumber: number): LineWidgetControl[] { | ||||
| 		const line = this.editor.state.doc.line(lineNumber + 1); | ||||
| 		const lineWidgets: LineWidgetControl[] = []; | ||||
|  | ||||
| 		this._effectDecorations.between(line.from, line.to, (from, to, decoration) => { | ||||
| 			if (from >= line.from && from <= line.to && from === to) { | ||||
| 				const widget = decoration.spec?.widget; | ||||
| 				if (widget && widget instanceof WidgetDecorationWrapper) { | ||||
| 					lineWidgets.push(this.createLineWidgetControl(widget.element, widget.options)); | ||||
| 				} | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		return lineWidgets; | ||||
| 	} | ||||
|  | ||||
| 	public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions): LineWidgetControl { | ||||
| 		const line = this.editor.state.doc.line(lineNumber + 1); | ||||
|  | ||||
| 		const lineWidgetOptions = { | ||||
| 			from: line.from, | ||||
| 			to: line.to, | ||||
| 			element: node, | ||||
| 			options, | ||||
| 		}; | ||||
| 		this.editor.dispatch({ | ||||
| 			effects: addLineWidgetEffect.of(lineWidgetOptions), | ||||
| 		}); | ||||
|  | ||||
| 		return this.createLineWidgetControl(node, options); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										46
									
								
								packages/editor/CodeMirror/CodeMirrorControl.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								packages/editor/CodeMirror/CodeMirrorControl.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import createEditor from './createEditor'; | ||||
| import createEditorSettings from './testUtil/createEditorSettings'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
|  | ||||
| const createEditorControls = (initialText: string) => { | ||||
| 	const editorSettings = createEditorSettings(Setting.THEME_LIGHT); | ||||
|  | ||||
| 	return createEditor(document.body, { | ||||
| 		initialText, | ||||
| 		settings: editorSettings, | ||||
| 		onEvent: _event => {}, | ||||
| 		onLogMessage: _message => {}, | ||||
| 	}); | ||||
| }; | ||||
|  | ||||
| describe('CodeMirrorControl', () => { | ||||
| 	it('clearHistory should clear the undo/redo history', () => { | ||||
| 		const controls = createEditorControls(''); | ||||
|  | ||||
| 		const insertedText = 'Testing... This is a test...'; | ||||
| 		controls.insertText(insertedText); | ||||
|  | ||||
| 		const fullInsertedText = insertedText; | ||||
| 		expect(controls.getValue()).toBe(fullInsertedText); | ||||
|  | ||||
| 		// Undo should work before clearing history | ||||
| 		controls.undo(); | ||||
| 		expect(controls.getValue()).toBe(''); | ||||
|  | ||||
| 		controls.redo(); | ||||
| 		controls.clearHistory(); | ||||
|  | ||||
| 		expect(controls.getValue()).toBe(fullInsertedText); | ||||
|  | ||||
| 		// Should not be able to undo cleared changes | ||||
| 		controls.undo(); | ||||
| 		expect(controls.getValue()).toBe(fullInsertedText); | ||||
|  | ||||
| 		// Should be able to undo new changes | ||||
| 		controls.insertText('!!!'); | ||||
| 		expect(controls.getValue()).toBe(`${fullInsertedText}!!!`); | ||||
|  | ||||
| 		controls.undo(); | ||||
| 		expect(controls.getValue()).toBe(fullInsertedText); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										137
									
								
								packages/editor/CodeMirror/CodeMirrorControl.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								packages/editor/CodeMirror/CodeMirrorControl.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| import { EditorView } from '@codemirror/view'; | ||||
| import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, PluginData, SearchState } from '../types'; | ||||
| import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation'; | ||||
| import editorCommands from './editorCommands/editorCommands'; | ||||
| import { EditorSelection, StateEffect } from '@codemirror/state'; | ||||
| import { updateLink } from './markdown/markdownCommands'; | ||||
| import { SearchQuery, setSearchQuery } from '@codemirror/search'; | ||||
| import PluginLoader from './PluginLoader'; | ||||
|  | ||||
| interface Callbacks { | ||||
| 	onUndoRedo(): void; | ||||
| 	onSettingsChange(newSettings: EditorSettings): void; | ||||
| 	onClearHistory(): void; | ||||
| 	onRemove(): void; | ||||
| 	onLogMessage: LogMessageCallback; | ||||
| } | ||||
|  | ||||
| export default class CodeMirrorControl extends CodeMirror5Emulation implements EditorControl { | ||||
| 	private _pluginControl: PluginLoader; | ||||
|  | ||||
| 	public constructor( | ||||
| 		editor: EditorView, | ||||
| 		private _callbacks: Callbacks, | ||||
| 	) { | ||||
| 		super(editor, _callbacks.onLogMessage); | ||||
|  | ||||
| 		this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage); | ||||
| 	} | ||||
|  | ||||
| 	public supportsCommand(name: string) { | ||||
| 		return name in editorCommands || super.commandExists(name); | ||||
| 	} | ||||
|  | ||||
| 	public override execCommand(name: string) { | ||||
| 		if (name in editorCommands) { | ||||
| 			editorCommands[name as EditorCommandType](this.editor); | ||||
| 		} else if (super.commandExists(name)) { | ||||
| 			super.execCommand(name); | ||||
| 		} | ||||
|  | ||||
| 		if (name === EditorCommandType.Undo || name === EditorCommandType.Redo) { | ||||
| 			this._callbacks.onUndoRedo(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public undo() { | ||||
| 		this.execCommand(EditorCommandType.Undo); | ||||
| 		this._callbacks.onUndoRedo(); | ||||
| 	} | ||||
|  | ||||
| 	public redo() { | ||||
| 		this.execCommand(EditorCommandType.Redo); | ||||
| 		this._callbacks.onUndoRedo(); | ||||
| 	} | ||||
|  | ||||
| 	public select(anchor: number, head: number) { | ||||
| 		this.editor.dispatch(this.editor.state.update({ | ||||
| 			selection: { anchor, head }, | ||||
| 			scrollIntoView: true, | ||||
| 		})); | ||||
| 	} | ||||
|  | ||||
| 	public clearHistory() { | ||||
| 		this._callbacks.onClearHistory(); | ||||
| 	} | ||||
|  | ||||
| 	public setScrollPercent(fraction: number) { | ||||
| 		const maxScroll = this.editor.scrollDOM.scrollHeight - this.editor.scrollDOM.clientHeight; | ||||
| 		this.editor.scrollDOM.scrollTop = fraction * maxScroll; | ||||
| 	} | ||||
|  | ||||
| 	public insertText(text: string) { | ||||
| 		this.editor.dispatch(this.editor.state.replaceSelection(text)); | ||||
| 	} | ||||
|  | ||||
| 	public updateBody(newBody: string) { | ||||
| 		// TODO: doc.toString() can be slow for large documents. | ||||
| 		const currentBody = this.editor.state.doc.toString(); | ||||
|  | ||||
| 		if (newBody !== currentBody) { | ||||
| 			// For now, collapse the selection to a single cursor | ||||
| 			// to ensure that the selection stays within the document | ||||
| 			// (and thus avoids an exception). | ||||
| 			const mainCursorPosition = this.editor.state.selection.main.anchor; | ||||
| 			const newCursorPosition = Math.min(mainCursorPosition, newBody.length); | ||||
|  | ||||
| 			this.editor.dispatch(this.editor.state.update({ | ||||
| 				changes: { | ||||
| 					from: 0, | ||||
| 					to: this.editor.state.doc.length, | ||||
| 					insert: newBody, | ||||
| 				}, | ||||
| 				selection: EditorSelection.cursor(newCursorPosition), | ||||
| 				scrollIntoView: true, | ||||
| 			})); | ||||
|  | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		return false; | ||||
| 	} | ||||
|  | ||||
| 	public updateLink(newLabel: string, newUrl: string) { | ||||
| 		updateLink(newLabel, newUrl)(this.editor); | ||||
| 	} | ||||
|  | ||||
| 	public updateSettings(newSettings: EditorSettings) { | ||||
| 		this._callbacks.onSettingsChange(newSettings); | ||||
| 	} | ||||
|  | ||||
| 	public setSearchState(newState: SearchState) { | ||||
| 		const query = new SearchQuery({ | ||||
| 			search: newState.searchText, | ||||
| 			caseSensitive: newState.caseSensitive, | ||||
| 			regexp: newState.useRegex, | ||||
| 			replace: newState.replaceText, | ||||
| 		}); | ||||
| 		this.editor.dispatch({ | ||||
| 			effects: setSearchQuery.of(query), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public addStyles(...styles: Parameters<typeof EditorView.theme>) { | ||||
| 		this.editor.dispatch({ | ||||
| 			effects: StateEffect.appendConfig.of(EditorView.theme(...styles)), | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public setPlugins(plugins: PluginData[]) { | ||||
| 		return this._pluginControl.setPlugins(plugins); | ||||
| 	} | ||||
|  | ||||
| 	public remove() { | ||||
| 		this._pluginControl.remove(); | ||||
| 		this._callbacks.onRemove(); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										156
									
								
								packages/editor/CodeMirror/PluginLoader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								packages/editor/CodeMirror/PluginLoader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| import { LogMessageCallback, PluginData } from '../types'; | ||||
| import CodeMirrorControl from './CodeMirrorControl'; | ||||
|  | ||||
| let pluginScriptIdCounter = 0; | ||||
|  | ||||
| type OnScriptLoadCallback = (exports: any)=> void; | ||||
| type OnPluginRemovedCallback = ()=> void; | ||||
|  | ||||
| export default class PluginLoader { | ||||
| 	private pluginScriptsContainer: HTMLElement; | ||||
| 	private loadedPluginIds: string[] = []; | ||||
| 	private pluginRemovalCallbacks: Record<string, OnPluginRemovedCallback> = {}; | ||||
|  | ||||
| 	public constructor(private editor: CodeMirrorControl, private logMessage: LogMessageCallback) { | ||||
| 		this.pluginScriptsContainer = document.createElement('div'); | ||||
| 		this.pluginScriptsContainer.style.display = 'none'; | ||||
|  | ||||
| 		// For testing | ||||
| 		this.pluginScriptsContainer.id = 'joplin-plugin-scripts-container'; | ||||
|  | ||||
| 		document.body.appendChild(this.pluginScriptsContainer); | ||||
|  | ||||
| 		(window as any).scriptLoadCallbacks ??= Object.create(null); | ||||
| 	} | ||||
|  | ||||
| 	public async setPlugins(plugins: PluginData[]) { | ||||
| 		for (const plugin of plugins) { | ||||
| 			if (!this.loadedPluginIds.includes(plugin.pluginId)) { | ||||
| 				this.addPlugin(plugin); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Remove old plugins | ||||
| 		const pluginIds = plugins.map(plugin => plugin.pluginId); | ||||
| 		const removedIds = this.loadedPluginIds | ||||
| 			.filter(id => !pluginIds.includes(id)); | ||||
|  | ||||
| 		for (const id of removedIds) { | ||||
| 			if (id in this.pluginRemovalCallbacks) { | ||||
| 				this.pluginRemovalCallbacks[id](); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private addPlugin(plugin: PluginData) { | ||||
| 		const onRemoveCallbacks: OnPluginRemovedCallback[] = []; | ||||
|  | ||||
| 		this.logMessage(`Loading plugin ${plugin.pluginId}`); | ||||
|  | ||||
| 		const addScript = (onLoad: OnScriptLoadCallback) => { | ||||
| 			const scriptElement = document.createElement('script'); | ||||
|  | ||||
| 			onRemoveCallbacks.push(() => { | ||||
| 				scriptElement.remove(); | ||||
| 			}); | ||||
|  | ||||
| 			void (async () => { | ||||
| 				const scriptId = pluginScriptIdCounter++; | ||||
| 				const js = await plugin.contentScriptJs(); | ||||
|  | ||||
| 				// Stop if cancelled | ||||
| 				if (!this.loadedPluginIds.includes(plugin.pluginId)) { | ||||
| 					return; | ||||
| 				} | ||||
|  | ||||
| 				scriptElement.innerText = ` | ||||
| 				(async () => { | ||||
| 					const exports = {}; | ||||
| 		 | ||||
| 					${js}; | ||||
| 		 | ||||
| 					window.scriptLoadCallbacks[${scriptId}](exports); | ||||
| 				})(); | ||||
| 				`; | ||||
|  | ||||
| 				(window as any).scriptLoadCallbacks[scriptId] = onLoad; | ||||
|  | ||||
| 				this.pluginScriptsContainer.appendChild(scriptElement); | ||||
| 			})(); | ||||
| 		}; | ||||
|  | ||||
| 		const addStyles = (cssStrings: string[]) => { | ||||
| 			// A container for style elements | ||||
| 			const styleContainer = document.createElement('div'); | ||||
|  | ||||
| 			onRemoveCallbacks.push(() => { | ||||
| 				styleContainer.remove(); | ||||
| 			}); | ||||
|  | ||||
| 			for (const cssText of cssStrings) { | ||||
| 				const style = document.createElement('style'); | ||||
| 				style.innerText = cssText; | ||||
| 				styleContainer.appendChild(style); | ||||
| 			} | ||||
|  | ||||
| 			this.pluginScriptsContainer.appendChild(styleContainer); | ||||
| 		}; | ||||
|  | ||||
| 		this.pluginRemovalCallbacks[plugin.pluginId] = () => { | ||||
| 			for (const callback of onRemoveCallbacks) { | ||||
| 				callback(); | ||||
| 			} | ||||
|  | ||||
| 			this.loadedPluginIds = this.loadedPluginIds.filter(id => { | ||||
| 				return id !== plugin.pluginId; | ||||
| 			}); | ||||
| 		}; | ||||
|  | ||||
| 		addScript(exports => { | ||||
| 			if (!exports?.default || !(typeof exports.default === 'function')) { | ||||
| 				throw new Error('All plugins must have a function default export'); | ||||
| 			} | ||||
|  | ||||
| 			const context = { | ||||
| 				postMessage: plugin.postMessageHandler, | ||||
| 				pluginId: plugin.pluginId, | ||||
| 				contentScriptId: plugin.contentScriptId, | ||||
| 			}; | ||||
| 			const loadedPlugin = exports.default(context); | ||||
|  | ||||
| 			loadedPlugin.plugin?.(this.editor); | ||||
|  | ||||
| 			if (loadedPlugin.codeMirrorOptions) { | ||||
| 				for (const key in loadedPlugin.codeMirrorOptions) { | ||||
| 					this.editor.setOption(key, loadedPlugin.codeMirrorOptions[key]); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (loadedPlugin.assets) { | ||||
| 				const cssStrings = []; | ||||
|  | ||||
| 				for (const asset of loadedPlugin.assets()) { | ||||
| 					if (!asset.inline) { | ||||
| 						this.logMessage('Warning: The CM6 plugin API currently only supports inline CSS.'); | ||||
| 						continue; | ||||
| 					} | ||||
|  | ||||
| 					if (asset.mime !== 'text/css') { | ||||
| 						throw new Error('Inline assets must have property "mime" set to "text/css"'); | ||||
| 					} | ||||
|  | ||||
| 					cssStrings.push(asset.text); | ||||
| 				} | ||||
|  | ||||
| 				addStyles(cssStrings); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		this.loadedPluginIds.push(plugin.pluginId); | ||||
| 	} | ||||
|  | ||||
| 	public remove() { | ||||
| 		this.pluginScriptsContainer.remove(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										70
									
								
								packages/editor/CodeMirror/configFromSettings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								packages/editor/CodeMirror/configFromSettings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import { EditorView, keymap } from '@codemirror/view'; | ||||
| import { closeBrackets } from '@codemirror/autocomplete'; | ||||
| import { EditorKeymap, EditorLanguageType, EditorSettings } from '../types'; | ||||
| import createTheme from './theme'; | ||||
| import { EditorState } from '@codemirror/state'; | ||||
| import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; | ||||
| import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; | ||||
| import { MarkdownMathExtension } from './markdown/markdownMathParser'; | ||||
| import syntaxHighlightingLanguages from './markdown/syntaxHighlightingLanguages'; | ||||
| import { html } from '@codemirror/lang-html'; | ||||
| import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands'; | ||||
| import { vim } from '@replit/codemirror-vim'; | ||||
| import { indentUnit } from '@codemirror/language'; | ||||
|  | ||||
| const configFromSettings = (settings: EditorSettings) => { | ||||
| 	const languageExtension = (() => { | ||||
| 		const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split(''); | ||||
|  | ||||
| 		const language = settings.language; | ||||
| 		if (language === EditorLanguageType.Markdown) { | ||||
| 			return [ | ||||
| 				markdown({ | ||||
| 					extensions: [ | ||||
| 						GitHubFlavoredMarkdownExtension, | ||||
|  | ||||
| 						// Don't highlight KaTeX if the user disabled it | ||||
| 						settings.katexEnabled ? MarkdownMathExtension : [], | ||||
| 					], | ||||
| 					codeLanguages: syntaxHighlightingLanguages, | ||||
| 				}), | ||||
| 				markdownLanguage.data.of({ closeBrackets: openingBrackets }), | ||||
| 			]; | ||||
| 		} else if (language === EditorLanguageType.Html) { | ||||
| 			return html(); | ||||
| 		} else { | ||||
| 			const exhaustivenessCheck: never = language; | ||||
| 			return exhaustivenessCheck; | ||||
| 		} | ||||
| 	})(); | ||||
|  | ||||
| 	const extensions = [ | ||||
| 		languageExtension, | ||||
| 		createTheme(settings.themeData), | ||||
| 		EditorView.contentAttributes.of({ | ||||
| 			autocapitalize: 'sentence', | ||||
| 			autocorrect: settings.spellcheckEnabled ? 'true' : 'false', | ||||
| 			spellcheck: settings.spellcheckEnabled ? 'true' : 'false', | ||||
| 		}), | ||||
| 		EditorState.readOnly.of(settings.readOnly), | ||||
| 		indentUnit.of(settings.indentWithTabs ? '\t' : '    '), | ||||
| 	]; | ||||
|  | ||||
| 	if (settings.automatchBraces) { | ||||
| 		extensions.push(closeBrackets()); | ||||
| 	} | ||||
|  | ||||
| 	if (settings.keymap === EditorKeymap.Vim) { | ||||
| 		extensions.push(vim()); | ||||
| 	} else if (settings.keymap === EditorKeymap.Emacs) { | ||||
| 		extensions.push(keymap.of(emacsStyleKeymap)); | ||||
| 	} | ||||
|  | ||||
| 	if (!settings.ignoreModifiers) { | ||||
| 		extensions.push(keymap.of(defaultKeymap)); | ||||
| 	} | ||||
|  | ||||
| 	return extensions; | ||||
| }; | ||||
|  | ||||
| export default configFromSettings; | ||||
							
								
								
									
										122
									
								
								packages/editor/CodeMirror/createEditor.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								packages/editor/CodeMirror/createEditor.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| /** | ||||
|  * @jest-environment jsdom | ||||
|  */ | ||||
|  | ||||
| import createEditor from './createEditor'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { forceParsing } from '@codemirror/language'; | ||||
| import loadLangauges from './testUtil/loadLanguages'; | ||||
|  | ||||
| import { expect, describe, it } from '@jest/globals'; | ||||
| import createEditorSettings from './testUtil/createEditorSettings'; | ||||
|  | ||||
|  | ||||
| describe('createEditor', () => { | ||||
| 	beforeAll(() => { | ||||
| 		jest.useFakeTimers(); | ||||
| 	}); | ||||
|  | ||||
| 	// This checks for a regression -- occasionally, when updating packages, | ||||
| 	// syntax highlighting in the CodeMirror editor stops working. This is usually | ||||
| 	// fixed by | ||||
| 	// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock, | ||||
| 	// 2. upgrading all CodeMirror packages to the latest versions in package.json, and | ||||
| 	// 3. re-running `yarn install`. | ||||
| 	// | ||||
| 	// See https://github.com/laurent22/joplin/issues/7253 | ||||
| 	it('should give headings a different style', async () => { | ||||
| 		const headerLineText = '# Testing...'; | ||||
| 		const initialText = `${headerLineText}\nThis is a test.`; | ||||
| 		const editorSettings = createEditorSettings(Setting.THEME_LIGHT); | ||||
|  | ||||
| 		await loadLangauges(); | ||||
| 		const editor = createEditor(document.body, { | ||||
| 			initialText, | ||||
| 			settings: editorSettings, | ||||
| 			onEvent: _event => {}, | ||||
| 			onLogMessage: _message => {}, | ||||
| 		}); | ||||
|  | ||||
| 		// Force the generation of the syntax tree now. | ||||
| 		forceParsing(editor.editor); | ||||
|  | ||||
| 		const headerLine = document.body.querySelector('.cm-headerLine')!; | ||||
| 		expect(headerLine.textContent).toBe(headerLineText); | ||||
|  | ||||
| 		// CodeMirror nests the tag that styles the header within .cm-headerLine: | ||||
| 		//  <div class='cm-headerLine'><span class='someclass'>Testing...</span></div> | ||||
| 		const headerLineContent = document.body.querySelectorAll('.cm-headerLine > span'); | ||||
| 		expect(headerLineContent.length).toBeGreaterThanOrEqual(1); | ||||
| 		for (const part of headerLineContent) { | ||||
| 			const style = getComputedStyle(part); | ||||
| 			expect(style.fontSize).toBe('1.6em'); | ||||
| 		} | ||||
|  | ||||
| 		// Cleanup | ||||
| 		editor.remove(); | ||||
| 	}); | ||||
|  | ||||
| 	it('should support loading plugins', async () => { | ||||
| 		const initialText = '# Test\nThis is a test.'; | ||||
| 		const editorSettings = createEditorSettings(Setting.THEME_LIGHT); | ||||
|  | ||||
| 		const editor = createEditor(document.body, { | ||||
| 			initialText, | ||||
| 			settings: editorSettings, | ||||
| 			onEvent: _event => {}, | ||||
| 			onLogMessage: _message => {}, | ||||
| 		}); | ||||
|  | ||||
| 		const getContentScriptJs = jest.fn(async () => { | ||||
| 			return ` | ||||
| 				exports.default = context => { | ||||
| 					context.postMessage(context.pluginId); | ||||
| 				}; | ||||
| 			`; | ||||
| 		}); | ||||
| 		const postMessageHandler = jest.fn(); | ||||
|  | ||||
| 		const testPlugin1 = { | ||||
| 			pluginId: 'a.plugin.id', | ||||
| 			contentScriptId: 'a.plugin.id.contentScript', | ||||
| 			contentScriptJs: getContentScriptJs, | ||||
| 			postMessageHandler, | ||||
| 		}; | ||||
| 		const testPlugin2 = { | ||||
| 			pluginId: 'another.plugin.id', | ||||
| 			contentScriptId: 'another.plugin.id.contentScript', | ||||
| 			contentScriptJs: getContentScriptJs, | ||||
| 			postMessageHandler, | ||||
| 		}; | ||||
|  | ||||
| 		// Should be able to load a plugin | ||||
| 		await editor.setPlugins([ | ||||
| 			testPlugin1, | ||||
| 		]); | ||||
|  | ||||
| 		// Allow plugins to load | ||||
| 		await jest.runAllTimersAsync(); | ||||
|  | ||||
| 		// Because plugin loading is done by adding script elements to the document, | ||||
| 		// we test for the presence of these script elements, rather than waiting for | ||||
| 		// them to run. | ||||
| 		expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(1); | ||||
|  | ||||
| 		// Only one script should be present. | ||||
| 		const scriptContainer = document.querySelector('#joplin-plugin-scripts-container'); | ||||
| 		expect(scriptContainer.querySelectorAll('script')).toHaveLength(1); | ||||
|  | ||||
| 		// Adding another plugin should add another script element | ||||
| 		await editor.setPlugins([ | ||||
| 			testPlugin2, testPlugin1, | ||||
| 		]); | ||||
| 		await jest.runAllTimersAsync(); | ||||
|  | ||||
| 		// There should now be script elements for each plugin | ||||
| 		expect(scriptContainer.querySelectorAll('script')).toHaveLength(2); | ||||
|  | ||||
| 		// Removing the editor should remove the script container | ||||
| 		editor.remove(); | ||||
| 		expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(0); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										303
									
								
								packages/editor/CodeMirror/createEditor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								packages/editor/CodeMirror/createEditor.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,303 @@ | ||||
| import { Compartment, EditorState } from '@codemirror/state'; | ||||
| import { indentOnInput, syntaxHighlighting } from '@codemirror/language'; | ||||
| import { | ||||
| 	openSearchPanel, closeSearchPanel, getSearchQuery, | ||||
| 	highlightSelectionMatches, search, | ||||
| } from '@codemirror/search'; | ||||
|  | ||||
| import { classHighlighter } from '@lezer/highlight'; | ||||
|  | ||||
| import { | ||||
| 	EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, | ||||
| } from '@codemirror/view'; | ||||
| import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands'; | ||||
|  | ||||
| import { keymap, KeyBinding } from '@codemirror/view'; | ||||
| import { searchKeymap } from '@codemirror/search'; | ||||
| import { historyKeymap } from '@codemirror/commands'; | ||||
|  | ||||
| import { SearchState, EditorProps, EditorSettings } from '../types'; | ||||
| import { EditorEventType, SelectionRangeChangeEvent } from '../events'; | ||||
| import { | ||||
| 	decreaseIndent, increaseIndent, | ||||
| 	toggleBolded, toggleCode, | ||||
| 	toggleItalicized, toggleMath, | ||||
| } from './markdown/markdownCommands'; | ||||
| import decoratorExtension from './markdown/decoratorExtension'; | ||||
| import computeSelectionFormatting from './markdown/computeSelectionFormatting'; | ||||
| import { selectionFormattingEqual } from '../SelectionFormatting'; | ||||
| import configFromSettings from './configFromSettings'; | ||||
| import getScrollFraction from './getScrollFraction'; | ||||
| import CodeMirrorControl from './CodeMirrorControl'; | ||||
|  | ||||
| const createEditor = ( | ||||
| 	parentElement: HTMLElement, props: EditorProps, | ||||
| ): CodeMirrorControl => { | ||||
| 	const initialText = props.initialText; | ||||
| 	let settings = props.settings; | ||||
|  | ||||
| 	props.onLogMessage('Initializing CodeMirror...'); | ||||
|  | ||||
| 	let searchVisible = false; | ||||
|  | ||||
| 	// Handles firing an event when the undo/redo stack changes | ||||
| 	let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null; | ||||
| 	let lastUndoDepth = 0; | ||||
| 	let lastRedoDepth = 0; | ||||
| 	const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => { | ||||
| 		if (schedulePostUndoRedoDepthChangeId_ !== null) { | ||||
| 			if (doItNow) { | ||||
| 				clearTimeout(schedulePostUndoRedoDepthChangeId_); | ||||
| 			} else { | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		schedulePostUndoRedoDepthChangeId_ = setTimeout(() => { | ||||
| 			schedulePostUndoRedoDepthChangeId_ = null; | ||||
| 			const newUndoDepth = undoDepth(editor.state); | ||||
| 			const newRedoDepth = redoDepth(editor.state); | ||||
|  | ||||
| 			if (newUndoDepth !== lastUndoDepth || newRedoDepth !== lastRedoDepth) { | ||||
| 				props.onEvent({ | ||||
| 					kind: EditorEventType.UndoRedoDepthChange, | ||||
| 					undoDepth: newUndoDepth, | ||||
| 					redoDepth: newRedoDepth, | ||||
| 				}); | ||||
| 				lastUndoDepth = newUndoDepth; | ||||
| 				lastRedoDepth = newRedoDepth; | ||||
| 			} | ||||
| 		}, doItNow ? 0 : 1000); | ||||
| 	}; | ||||
|  | ||||
| 	let currentDocText = props.initialText; | ||||
| 	const notifyDocChanged = (viewUpdate: ViewUpdate) => { | ||||
| 		if (viewUpdate.docChanged) { | ||||
| 			currentDocText = editor.state.doc.toString(); | ||||
| 			props.onEvent({ | ||||
| 				kind: EditorEventType.Change, | ||||
| 				value: currentDocText, | ||||
| 			}); | ||||
|  | ||||
| 			schedulePostUndoRedoDepthChange(editor); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const notifyLinkEditRequest = () => { | ||||
| 		props.onEvent({ | ||||
| 			kind: EditorEventType.EditLink, | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const onSearchDialogUpdate = () => { | ||||
| 		const query = getSearchQuery(editor.state); | ||||
| 		const searchState: SearchState = { | ||||
| 			searchText: query.search, | ||||
| 			replaceText: query.replace, | ||||
| 			useRegex: query.regexp, | ||||
| 			caseSensitive: query.caseSensitive, | ||||
| 			dialogVisible: searchVisible, | ||||
| 		}; | ||||
| 		props.onEvent({ | ||||
| 			kind: EditorEventType.UpdateSearchDialog, | ||||
| 			searchState, | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const showSearchDialog = () => { | ||||
| 		if (!searchVisible) { | ||||
| 			openSearchPanel(editor); | ||||
| 		} | ||||
| 		searchVisible = true; | ||||
| 		onSearchDialogUpdate(); | ||||
| 	}; | ||||
|  | ||||
| 	const hideSearchDialog = () => { | ||||
| 		if (searchVisible) { | ||||
| 			closeSearchPanel(editor); | ||||
| 		} | ||||
| 		searchVisible = false; | ||||
| 		onSearchDialogUpdate(); | ||||
| 	}; | ||||
|  | ||||
| 	const globalSpellcheckEnabled = () => { | ||||
| 		return editor.contentDOM.spellcheck; | ||||
| 	}; | ||||
|  | ||||
| 	const notifySelectionChange = (viewUpdate: ViewUpdate) => { | ||||
| 		if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { | ||||
| 			const mainRange = viewUpdate.state.selection.main; | ||||
| 			const event: SelectionRangeChangeEvent = { | ||||
| 				kind: EditorEventType.SelectionRangeChange, | ||||
|  | ||||
| 				anchor: mainRange.anchor, | ||||
| 				head: mainRange.head, | ||||
| 				from: mainRange.from, | ||||
| 				to: mainRange.to, | ||||
| 			}; | ||||
| 			props.onEvent(event); | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => { | ||||
| 		const spellcheck = globalSpellcheckEnabled(); | ||||
|  | ||||
| 		// If we can't determine the previous formatting, post the update regardless | ||||
| 		if (!viewUpdate) { | ||||
| 			const formatting = computeSelectionFormatting(editor.state, spellcheck); | ||||
| 			props.onEvent({ | ||||
| 				kind: EditorEventType.SelectionFormattingChange, | ||||
| 				formatting, | ||||
| 			}); | ||||
| 		} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { | ||||
|  | ||||
| 			// Only post the update if something changed | ||||
| 			const oldFormatting = computeSelectionFormatting(viewUpdate.startState, spellcheck); | ||||
| 			const newFormatting = computeSelectionFormatting(viewUpdate.state, spellcheck); | ||||
|  | ||||
| 			if (!selectionFormattingEqual(oldFormatting, newFormatting)) { | ||||
| 				props.onEvent({ | ||||
| 					kind: EditorEventType.SelectionFormattingChange, | ||||
| 					formatting: newFormatting, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	// Returns a keyboard command that returns true (so accepts the keybind) | ||||
| 	// alwaysActive: true if this command should be registered even if ignoreModifiers is given. | ||||
| 	const keyCommand = (key: string, run: Command, alwaysActive?: boolean): KeyBinding => { | ||||
| 		return { | ||||
| 			key, | ||||
| 			run: editor => { | ||||
| 				if (settings.ignoreModifiers && !alwaysActive) return false; | ||||
|  | ||||
| 				return run(editor); | ||||
| 			}, | ||||
| 		}; | ||||
| 	}; | ||||
|  | ||||
| 	const historyCompartment = new Compartment(); | ||||
| 	const dynamicConfig = new Compartment(); | ||||
|  | ||||
| 	const editor = new EditorView({ | ||||
| 		state: EditorState.create({ | ||||
| 			// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts | ||||
| 			// for a sample configuration. | ||||
| 			extensions: [ | ||||
| 				dynamicConfig.of(configFromSettings(props.settings)), | ||||
| 				historyCompartment.of(history()), | ||||
|  | ||||
| 				search(settings.useExternalSearch ? { | ||||
| 					createPanel(_: EditorView) { | ||||
| 						return { | ||||
| 							// The actual search dialog is implemented with react native, | ||||
| 							// use a dummy element. | ||||
| 							dom: document.createElement('div'), | ||||
| 							mount() { | ||||
| 								showSearchDialog(); | ||||
| 							}, | ||||
| 							destroy() { | ||||
| 								hideSearchDialog(); | ||||
| 							}, | ||||
| 						}; | ||||
| 					}, | ||||
| 				} : undefined), | ||||
| 				drawSelection(), | ||||
| 				highlightSpecialChars(), | ||||
| 				highlightSelectionMatches(), | ||||
| 				indentOnInput(), | ||||
|  | ||||
| 				EditorView.domEventHandlers({ | ||||
| 					scroll: (_event, view) => { | ||||
| 						props.onEvent({ | ||||
| 							kind: EditorEventType.Scroll, | ||||
| 							fraction: getScrollFraction(view), | ||||
| 						}); | ||||
| 					}, | ||||
| 				}), | ||||
|  | ||||
| 				EditorState.tabSize.of(4), | ||||
|  | ||||
| 				// Apply styles to entire lines (block-display decorations) | ||||
| 				decoratorExtension, | ||||
|  | ||||
| 				// Adds additional CSS classes to tokens (the default CSS classes are | ||||
| 				// auto-generated and thus unstable). | ||||
| 				syntaxHighlighting(classHighlighter), | ||||
|  | ||||
| 				EditorView.lineWrapping, | ||||
| 				EditorView.updateListener.of((viewUpdate: ViewUpdate) => { | ||||
| 					notifyDocChanged(viewUpdate); | ||||
| 					notifySelectionChange(viewUpdate); | ||||
| 					notifySelectionFormattingChange(viewUpdate); | ||||
| 				}), | ||||
| 				keymap.of([ | ||||
| 					// Custom mod-f binding: Toggle the external dialog implementation | ||||
| 					// (don't show/hide the Panel dialog). | ||||
| 					keyCommand('Mod-f', (_: EditorView) => { | ||||
| 						if (searchVisible) { | ||||
| 							hideSearchDialog(); | ||||
| 						} else { | ||||
| 							showSearchDialog(); | ||||
| 						} | ||||
| 						return true; | ||||
| 					}), | ||||
| 					// Markdown formatting keyboard shortcuts | ||||
| 					keyCommand('Mod-b', toggleBolded), | ||||
| 					keyCommand('Mod-i', toggleItalicized), | ||||
| 					keyCommand('Mod-$', toggleMath), | ||||
| 					keyCommand('Mod-`', toggleCode), | ||||
| 					keyCommand('Mod-[', decreaseIndent), | ||||
| 					keyCommand('Mod-]', increaseIndent), | ||||
| 					keyCommand('Mod-k', (_: EditorView) => { | ||||
| 						notifyLinkEditRequest(); | ||||
| 						return true; | ||||
| 					}), | ||||
| 					keyCommand('Tab', increaseIndent, true), | ||||
| 					keyCommand('Shift-Tab', decreaseIndent, true), | ||||
|  | ||||
| 					...standardKeymap, ...historyKeymap, ...searchKeymap, | ||||
| 				]), | ||||
| 			], | ||||
| 			doc: initialText, | ||||
| 		}), | ||||
| 		parent: parentElement, | ||||
| 	}); | ||||
|  | ||||
| 	const editorControls = new CodeMirrorControl(editor, { | ||||
| 		onClearHistory: () => { | ||||
| 			// Clear history by removing then re-add the history extension. | ||||
| 			// Just re-adding the history extension isn't enough. | ||||
| 			editor.dispatch({ | ||||
| 				effects: historyCompartment.reconfigure([]), | ||||
| 			}); | ||||
| 			editor.dispatch({ | ||||
| 				effects: historyCompartment.reconfigure(history()), | ||||
| 			}); | ||||
| 		}, | ||||
| 		onSettingsChange: (newSettings: EditorSettings) => { | ||||
| 			settings = newSettings; | ||||
| 			editor.dispatch({ | ||||
| 				effects: dynamicConfig.reconfigure( | ||||
| 					configFromSettings(newSettings), | ||||
| 				), | ||||
| 			}); | ||||
| 		}, | ||||
| 		onUndoRedo: () => { | ||||
| 			// This callback is triggered when undo/redo is called | ||||
| 			// directly. Show visual feedback immediately. | ||||
| 			schedulePostUndoRedoDepthChange(editor, true); | ||||
| 		}, | ||||
| 		onLogMessage: props.onLogMessage, | ||||
| 		onRemove: () => { | ||||
| 			editor.destroy(); | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	return editorControls; | ||||
| }; | ||||
|  | ||||
| export default createEditor; | ||||
|  | ||||
|  | ||||
							
								
								
									
										69
									
								
								packages/editor/CodeMirror/editorCommands/editorCommands.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								packages/editor/CodeMirror/editorCommands/editorCommands.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import { EditorView } from '@codemirror/view'; | ||||
| import { EditorCommandType, ListType } from '../../types'; | ||||
| import { undo, redo, selectAll, indentSelection, cursorDocStart, cursorDocEnd, cursorLineStart, cursorLineEnd, deleteToLineStart, deleteToLineEnd, undoSelection, redoSelection, cursorPageDown, cursorPageUp, cursorCharRight, cursorCharLeft, insertNewlineAndIndent, cursorLineDown, cursorLineUp } from '@codemirror/commands'; | ||||
| import { | ||||
| 	decreaseIndent, increaseIndent, | ||||
| 	toggleBolded, toggleCode, | ||||
| 	toggleHeaderLevel, toggleItalicized, | ||||
| 	toggleList, toggleMath, | ||||
| } from '../markdown/markdownCommands'; | ||||
| import swapLine, { SwapLineDirection } from './swapLine'; | ||||
| import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext } from '@codemirror/search'; | ||||
|  | ||||
| type EditorCommandFunction = (editor: EditorView)=> void; | ||||
|  | ||||
| const editorCommands: Record<EditorCommandType, EditorCommandFunction> = { | ||||
| 	[EditorCommandType.Undo]: undo, | ||||
| 	[EditorCommandType.Redo]: redo, | ||||
| 	[EditorCommandType.SelectAll]: selectAll, | ||||
| 	[EditorCommandType.Focus]: editor => editor.focus(), | ||||
|  | ||||
| 	[EditorCommandType.ToggleBolded]: toggleBolded, | ||||
| 	[EditorCommandType.ToggleItalicized]: toggleItalicized, | ||||
| 	[EditorCommandType.ToggleCode]: toggleCode, | ||||
| 	[EditorCommandType.ToggleMath]: toggleMath, | ||||
| 	[EditorCommandType.ToggleNumberedList]: toggleList(ListType.OrderedList), | ||||
| 	[EditorCommandType.ToggleBulletedList]: toggleList(ListType.UnorderedList), | ||||
| 	[EditorCommandType.ToggleCheckList]: toggleList(ListType.CheckList), | ||||
| 	[EditorCommandType.ToggleHeading]: toggleHeaderLevel(2), | ||||
| 	[EditorCommandType.ToggleHeading1]: toggleHeaderLevel(1), | ||||
| 	[EditorCommandType.ToggleHeading2]: toggleHeaderLevel(2), | ||||
| 	[EditorCommandType.ToggleHeading3]: toggleHeaderLevel(3), | ||||
| 	[EditorCommandType.ToggleHeading4]: toggleHeaderLevel(4), | ||||
| 	[EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5), | ||||
|  | ||||
| 	[EditorCommandType.ScrollSelectionIntoView]: editor => { | ||||
| 		editor.dispatch(editor.state.update({ | ||||
| 			scrollIntoView: true, | ||||
| 		})); | ||||
| 	}, | ||||
| 	[EditorCommandType.DeleteToLineEnd]: deleteToLineEnd, | ||||
| 	[EditorCommandType.DeleteToLineStart]: deleteToLineStart, | ||||
| 	[EditorCommandType.IndentMore]: increaseIndent, | ||||
| 	[EditorCommandType.IndentLess]: decreaseIndent, | ||||
| 	[EditorCommandType.IndentAuto]: indentSelection, | ||||
| 	[EditorCommandType.InsertNewlineAndIndent]: insertNewlineAndIndent, | ||||
| 	[EditorCommandType.SwapLineUp]: swapLine(SwapLineDirection.Up), | ||||
| 	[EditorCommandType.SwapLineDown]: swapLine(SwapLineDirection.Down), | ||||
| 	[EditorCommandType.GoDocEnd]: cursorDocEnd, | ||||
| 	[EditorCommandType.GoDocStart]: cursorDocStart, | ||||
| 	[EditorCommandType.GoLineStart]: cursorLineStart, | ||||
| 	[EditorCommandType.GoLineEnd]: cursorLineEnd, | ||||
| 	[EditorCommandType.GoLineUp]: cursorLineUp, | ||||
| 	[EditorCommandType.GoLineDown]: cursorLineDown, | ||||
| 	[EditorCommandType.GoPageUp]: cursorPageUp, | ||||
| 	[EditorCommandType.GoPageDown]: cursorPageDown, | ||||
| 	[EditorCommandType.GoCharLeft]: cursorCharLeft, | ||||
| 	[EditorCommandType.GoCharRight]: cursorCharRight, | ||||
| 	[EditorCommandType.UndoSelection]: undoSelection, | ||||
| 	[EditorCommandType.RedoSelection]: redoSelection, | ||||
|  | ||||
| 	[EditorCommandType.ShowSearch]: openSearchPanel, | ||||
| 	[EditorCommandType.HideSearch]: closeSearchPanel, | ||||
| 	[EditorCommandType.FindNext]: findNext, | ||||
| 	[EditorCommandType.FindPrevious]: findPrevious, | ||||
| 	[EditorCommandType.ReplaceNext]: replaceNext, | ||||
| 	[EditorCommandType.ReplaceAll]: replaceAll, | ||||
| }; | ||||
| export default editorCommands; | ||||
|  | ||||
							
								
								
									
										11
									
								
								packages/editor/CodeMirror/editorCommands/supportsCommand.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/editor/CodeMirror/editorCommands/supportsCommand.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
|  | ||||
| import { EditorCommandType } from '../../types'; | ||||
|  | ||||
| // The CodeMirror 6 editor supports all EditorCommandTypes. | ||||
| // Note that this file is separate from editorCommands.ts to allow importing it in | ||||
| // non-browser contexts. | ||||
| const supportsCommand = (commandName: EditorCommandType) => { | ||||
| 	return Object.values(EditorCommandType).includes(commandName); | ||||
| }; | ||||
|  | ||||
| export default supportsCommand; | ||||
							
								
								
									
										49
									
								
								packages/editor/CodeMirror/editorCommands/swapLine.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/editor/CodeMirror/editorCommands/swapLine.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import { EditorSelection } from '@codemirror/state'; | ||||
| import { Command, EditorView } from '@codemirror/view'; | ||||
|  | ||||
| export enum SwapLineDirection { | ||||
| 	Up = -1, | ||||
| 	Down = 1, | ||||
| } | ||||
|  | ||||
| const swapLine = (direction: SwapLineDirection): Command => (editor: EditorView) => { | ||||
| 	const state = editor.state; | ||||
| 	const doc = state.doc; | ||||
|  | ||||
| 	const transaction = state.changeByRange(range => { | ||||
| 		const currentLine = doc.lineAt(range.anchor); | ||||
| 		const otherLineNumber = currentLine.number + direction; | ||||
|  | ||||
| 		// Out of range? No changes. | ||||
| 		if (otherLineNumber <= 0 || otherLineNumber > doc.lines) { | ||||
| 			return { range }; | ||||
| 		} | ||||
|  | ||||
| 		const otherLine = doc.line(otherLineNumber); | ||||
|  | ||||
| 		let deltaPos; | ||||
| 		if (direction === SwapLineDirection.Down) { | ||||
| 			// +1: include newline | ||||
| 			deltaPos = otherLine.length + 1; | ||||
| 		} else { | ||||
| 			deltaPos = otherLine.from - currentLine.from; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			range: EditorSelection.range(range.anchor + deltaPos, range.head + deltaPos), | ||||
| 			changes: [{ | ||||
| 				from: currentLine.from, | ||||
| 				to: currentLine.to, | ||||
| 				insert: otherLine.text, | ||||
| 			}, { | ||||
| 				from: otherLine.from, | ||||
| 				to: otherLine.to, | ||||
| 				insert: currentLine.text, | ||||
| 			}], | ||||
| 		}; | ||||
| 	}); | ||||
|  | ||||
| 	editor.dispatch(transaction); | ||||
| 	return true; | ||||
| }; | ||||
| export default swapLine; | ||||
							
								
								
									
										11
									
								
								packages/editor/CodeMirror/getScrollFraction.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/editor/CodeMirror/getScrollFraction.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { EditorView } from '@codemirror/view'; | ||||
|  | ||||
|  | ||||
| const getScrollFraction = (view: EditorView) => { | ||||
| 	const maxScroll = view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight; | ||||
|  | ||||
| 	// Prevent division by zero | ||||
| 	return maxScroll > 0 ? view.scrollDOM.scrollTop / maxScroll : 0; | ||||
| }; | ||||
|  | ||||
| export default getScrollFraction; | ||||
| @@ -0,0 +1,123 @@ | ||||
| import SelectionFormatting, { MutableSelectionFormatting, defaultSelectionFormatting } from '../../SelectionFormatting'; | ||||
| import { syntaxTree } from '@codemirror/language'; | ||||
| import { EditorState } from '@codemirror/state'; | ||||
|  | ||||
| const computeSelectionFormatting = (state: EditorState, globalSpellcheck: boolean): SelectionFormatting => { | ||||
| 	const range = state.selection.main; | ||||
| 	const formatting: MutableSelectionFormatting = { | ||||
| 		...defaultSelectionFormatting, | ||||
| 		selectedText: state.doc.sliceString(range.from, range.to), | ||||
| 		spellChecking: globalSpellcheck, | ||||
| 	}; | ||||
|  | ||||
| 	const parseLinkData = (nodeText: string) => { | ||||
| 		const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/); | ||||
|  | ||||
| 		if (linkMatch) { | ||||
| 			return { | ||||
| 				linkText: linkMatch[1], | ||||
| 				linkURL: linkMatch[2], | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return null; | ||||
| 	}; | ||||
|  | ||||
| 	// Find nodes that overlap/are within the selected region | ||||
| 	syntaxTree(state).iterate({ | ||||
| 		from: range.from, to: range.to, | ||||
| 		enter: node => { | ||||
| 			// Checklists don't have a specific containing node. As such, | ||||
| 			// we're in a checklist if we've selected a 'Task' node. | ||||
| 			if (node.name === 'Task') { | ||||
| 				formatting.inChecklist = true; | ||||
| 			} | ||||
|  | ||||
| 			// Only handle notes that contain the entire range. | ||||
| 			if (node.from > range.from || node.to < range.to) { | ||||
| 				return; | ||||
| 			} | ||||
| 			// Lazily compute the node's text | ||||
| 			const nodeText = () => state.doc.sliceString(node.from, node.to); | ||||
|  | ||||
| 			switch (node.name) { | ||||
| 			case 'StrongEmphasis': | ||||
| 				formatting.bolded = true; | ||||
| 				break; | ||||
| 			case 'Emphasis': | ||||
| 				formatting.italicized = true; | ||||
| 				break; | ||||
| 			case 'ListItem': | ||||
| 				formatting.listLevel += 1; | ||||
| 				break; | ||||
| 			case 'BulletList': | ||||
| 				formatting.inUnorderedList = true; | ||||
| 				break; | ||||
| 			case 'OrderedList': | ||||
| 				formatting.inOrderedList = true; | ||||
| 				break; | ||||
| 			case 'TaskList': | ||||
| 				formatting.inChecklist = true; | ||||
| 				break; | ||||
| 			case 'InlineCode': | ||||
| 			case 'FencedCode': | ||||
| 				formatting.inCode = true; | ||||
| 				formatting.unspellCheckableRegion = true; | ||||
| 				break; | ||||
| 			case 'InlineMath': | ||||
| 			case 'BlockMath': | ||||
| 				formatting.inMath = true; | ||||
| 				formatting.unspellCheckableRegion = true; | ||||
| 				break; | ||||
| 			case 'ATXHeading1': | ||||
| 				formatting.headerLevel = 1; | ||||
| 				break; | ||||
| 			case 'ATXHeading2': | ||||
| 				formatting.headerLevel = 2; | ||||
| 				break; | ||||
| 			case 'ATXHeading3': | ||||
| 				formatting.headerLevel = 3; | ||||
| 				break; | ||||
| 			case 'ATXHeading4': | ||||
| 				formatting.headerLevel = 4; | ||||
| 				break; | ||||
| 			case 'ATXHeading5': | ||||
| 				formatting.headerLevel = 5; | ||||
| 				break; | ||||
| 			case 'URL': | ||||
| 				formatting.inLink = true; | ||||
| 				formatting.linkData = { | ||||
| 					...formatting.linkData, | ||||
| 					linkURL: nodeText(), | ||||
| 				}; | ||||
| 				formatting.unspellCheckableRegion = true; | ||||
| 				break; | ||||
| 			case 'Link': | ||||
| 				formatting.inLink = true; | ||||
| 				formatting.linkData = parseLinkData(nodeText()); | ||||
| 				break; | ||||
| 			} | ||||
| 		}, | ||||
| 	}); | ||||
|  | ||||
| 	// The markdown parser marks checklists as unordered lists. Ensure | ||||
| 	// that they aren't marked as such. | ||||
| 	if (formatting.inChecklist) { | ||||
| 		if (!formatting.inUnorderedList) { | ||||
| 			// Even if the selection contains a Task, because an unordered list node | ||||
| 			// must contain a valid Task node, we're only in a checklist if we're also in | ||||
| 			// an unordered list. | ||||
| 			formatting.inChecklist = false; | ||||
| 		} else { | ||||
| 			formatting.inUnorderedList = false; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if (formatting.unspellCheckableRegion) { | ||||
| 		formatting.spellChecking = false; | ||||
| 	} | ||||
|  | ||||
| 	return formatting; | ||||
| }; | ||||
| export default computeSelectionFormatting; | ||||
|  | ||||
| @@ -40,6 +40,10 @@ const urlDecoration = Decoration.mark({ | ||||
| 	attributes: { class: 'cm-url', ...noSpellCheckAttrs }, | ||||
| }); | ||||
| 
 | ||||
| const htmlTagNameDecoration = Decoration.mark({ | ||||
| 	attributes: { class: 'cm-htmlTag', ...noSpellCheckAttrs }, | ||||
| }); | ||||
| 
 | ||||
| const blockQuoteDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-blockQuote' }, | ||||
| }); | ||||
| @@ -48,6 +52,26 @@ const headerLineDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-headerLine' }, | ||||
| }); | ||||
| 
 | ||||
| const tableHeaderDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-tableHeader' }, | ||||
| }); | ||||
| 
 | ||||
| const tableBodyDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-tableRow' }, | ||||
| }); | ||||
| 
 | ||||
| const tableDelimiterDecoration = Decoration.line({ | ||||
| 	attributes: { class: 'cm-tableDelimiter' }, | ||||
| }); | ||||
| 
 | ||||
| const horizontalRuleDecoration = Decoration.mark({ | ||||
| 	attributes: { class: 'cm-hr' }, | ||||
| }); | ||||
| 
 | ||||
| const taskMarkerDecoration = Decoration.mark({ | ||||
| 	attributes: { class: 'cm-taskMarker' }, | ||||
| }); | ||||
| 
 | ||||
| type DecorationDescription = { pos: number; length?: number; decoration: Decoration }; | ||||
| 
 | ||||
| // Returns a set of [Decoration]s, associated with block syntax groups that require
 | ||||
| @@ -125,6 +149,25 @@ const computeDecorations = (view: EditorView) => { | ||||
| 				case 'ATXHeading6': | ||||
| 					addDecorationToLines(viewFrom, viewTo, headerLineDecoration); | ||||
| 					break; | ||||
| 				case 'HTMLTag': | ||||
| 				case 'TagName': | ||||
| 					addDecorationToRange(viewFrom, viewTo, htmlTagNameDecoration); | ||||
| 					break; | ||||
| 				case 'TableHeader': | ||||
| 					addDecorationToLines(viewFrom, viewTo, tableHeaderDecoration); | ||||
| 					break; | ||||
| 				case 'TableDelimiter': | ||||
| 					addDecorationToLines(viewFrom, viewTo, tableDelimiterDecoration); | ||||
| 					break; | ||||
| 				case 'TableRow': | ||||
| 					addDecorationToLines(viewFrom, viewTo, tableBodyDecoration); | ||||
| 					break; | ||||
| 				case 'HorizontalRule': | ||||
| 					addDecorationToRange(viewFrom, viewTo, horizontalRuleDecoration); | ||||
| 					break; | ||||
| 				case 'TaskMarker': | ||||
| 					addDecorationToRange(viewFrom, viewTo, taskMarkerDecoration); | ||||
| 					break; | ||||
| 				} | ||||
| 
 | ||||
| 				// Only block decorations will have differing first and last lines
 | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { EditorSelection } from '@codemirror/state'; | ||||
| import { ListType } from '../types'; | ||||
| import createEditor from './testUtil/createEditor'; | ||||
| import { ListType } from '../../types'; | ||||
| import createTestEditor from '../testUtil/createTestEditor'; | ||||
| import { toggleList } from './markdownCommands'; | ||||
| 
 | ||||
| describe('markdownCommands.bulletedVsChecklist', () => { | ||||
| @@ -13,7 +13,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { | ||||
| 	const expectedTags = ['BulletList', 'Task']; | ||||
| 
 | ||||
| 	it('should remove a checklist following a bulleted list without modifying the bulleted list', async () => { | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.cursor(bulletedListPart.length + 5), expectedTags, | ||||
| 		); | ||||
| 
 | ||||
| @@ -24,7 +24,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should remove an unordered list following a checklist without modifying the checklist', async () => { | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.cursor(bulletedListPart.length - 5), expectedTags, | ||||
| 		); | ||||
| 
 | ||||
| @@ -35,7 +35,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should replace a selection of unordered and task lists with a correctly-numbered list', async () => { | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.range(0, initialDocText.length), expectedTags, | ||||
| 		); | ||||
| 
 | ||||
| @@ -2,7 +2,7 @@ import { EditorSelection } from '@codemirror/state'; | ||||
| import { | ||||
| 	toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink, | ||||
| } from './markdownCommands'; | ||||
| import createEditor from './testUtil/createEditor'; | ||||
| import createTestEditor from '../testUtil/createTestEditor'; | ||||
| import { blockMathTagName } from './markdownMathParser'; | ||||
| 
 | ||||
| describe('markdownCommands', () => { | ||||
| @@ -11,7 +11,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('should bold/italicize everything selected', async () => { | ||||
| 		const initialDocText = 'Testing...'; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.range(0, initialDocText.length), [], | ||||
| 		); | ||||
| 
 | ||||
| @@ -38,7 +38,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('for a cursor, bolding, then italicizing, should produce a bold-italic region', async () => { | ||||
| 		const initialDocText = ''; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.cursor(0), [], | ||||
| 		); | ||||
| 
 | ||||
| @@ -56,7 +56,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling math should both create and navigate out of math regions', async () => { | ||||
| 		const initialDocText = 'Testing... '; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 
 | ||||
| 		toggleMath(editor); | ||||
| 		expect(editor.state.doc.toString()).toBe('Testing... $$'); | ||||
| @@ -72,7 +72,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling inline code should both create and navigate out of an inline code region', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n'; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 
 | ||||
| 		toggleCode(editor); | ||||
| 		editor.dispatch(editor.state.replaceSelection('f(x) = ...')); | ||||
| @@ -84,7 +84,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('should set headers to the proper levels (when toggling)', async () => { | ||||
| 		const initialDocText = 'Testing...\nThis is a test.'; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(3), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(3), []); | ||||
| 
 | ||||
| 		toggleHeaderLevel(1)(editor); | ||||
| 
 | ||||
| @@ -110,7 +110,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('headers should toggle properly within block quotes', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test'; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor('Testing...\n\n> This'.length), | ||||
| 			['Blockquote'], | ||||
| @@ -134,7 +134,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('block math should be created correctly within block quotes', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test'; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.range( | ||||
| 				'Testing...\n\n> This'.length, | ||||
| @@ -157,7 +157,7 @@ describe('markdownCommands', () => { | ||||
| 	it('block math should be correctly removed within block quotes', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test'; | ||||
| 
 | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length), | ||||
| 			['Blockquote', blockMathTagName], | ||||
| @@ -173,7 +173,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('updateLink should replace link titles and isolate URLs if no title is given', async () => { | ||||
| 		const initialDocText = '[foo](http://example.com/)'; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']); | ||||
| 
 | ||||
| 		updateLink('bar', 'https://example.com/')(editor); | ||||
| 		expect(editor.state.doc.toString()).toBe( | ||||
| @@ -188,7 +188,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling math twice, starting on a line with content, should a math block', async () => { | ||||
| 		const initialDocText = 'Testing... '; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 
 | ||||
| 		toggleMath(editor); | ||||
| 		toggleMath(editor); | ||||
| @@ -198,7 +198,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling math twice on an empty line should create an empty math block', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n'; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 
 | ||||
| 		toggleMath(editor); | ||||
| 		toggleMath(editor); | ||||
| @@ -208,7 +208,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling code twice on an empty line should create an empty code block', async () => { | ||||
| 		const initialDocText = 'Testing...\n\n'; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); | ||||
| 
 | ||||
| 		// Toggling code twice should create a block code region
 | ||||
| 		toggleCode(editor); | ||||
| @@ -222,7 +222,7 @@ describe('markdownCommands', () => { | ||||
| 
 | ||||
| 	it('toggling math twice inside a block quote should produce an empty math block', async () => { | ||||
| 		const initialDocText = '> Testing...> \n> '; | ||||
| 		const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']); | ||||
| 		const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']); | ||||
| 
 | ||||
| 		toggleMath(editor); | ||||
| 		toggleMath(editor); | ||||
| @@ -2,8 +2,8 @@ import { EditorSelection, EditorState } from '@codemirror/state'; | ||||
| import { | ||||
| 	increaseIndent, toggleList, | ||||
| } from './markdownCommands'; | ||||
| import { ListType } from '../types'; | ||||
| import createEditor from './testUtil/createEditor'; | ||||
| import { ListType } from '../../types'; | ||||
| import createTestEditor from '../testUtil/createTestEditor'; | ||||
| 
 | ||||
| describe('markdownCommands.toggleList', () => { | ||||
| 
 | ||||
| @@ -12,7 +12,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 	it('should remove the same type of list', async () => { | ||||
| 		const initialDocText = '- testing\n- this is a `test`\n'; | ||||
| 
 | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor(5), | ||||
| 			['BulletList', 'InlineCode'], | ||||
| @@ -26,7 +26,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 
 | ||||
| 	it('should insert a numbered list with correct numbering', async () => { | ||||
| 		const initialDocText = 'Testing...\nThis is a test\nof list toggling...'; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor('Testing...\nThis is a'.length), | ||||
| 			[], | ||||
| @@ -51,7 +51,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 	const unorderedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7'; | ||||
| 
 | ||||
| 	it('should correctly replace an unordered list with a numbered list', async () => { | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			unorderedListText, | ||||
| 			EditorSelection.cursor(unorderedListText.length), | ||||
| 			['BulletList'], | ||||
| @@ -154,7 +154,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 	it('should toggle a numbered list without changing its sublists', async () => { | ||||
| 		const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo'; | ||||
| 
 | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor(0), | ||||
| 			['OrderedList', 'BulletList'], | ||||
| @@ -169,7 +169,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 	it('should toggle a sublist without changing the parent list', async () => { | ||||
| 		const initialDocText = '1. This\n2. is\n3. '; | ||||
| 
 | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, | ||||
| 			EditorSelection.cursor(initialDocText.length), | ||||
| 			['OrderedList'], | ||||
| @@ -192,7 +192,7 @@ describe('markdownCommands.toggleList', () => { | ||||
| 	it('should toggle lists properly within block quotes', async () => { | ||||
| 		const preSubListText = '> # List test\n> * This\n> * is\n'; | ||||
| 		const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`; | ||||
| 		const editor = await createEditor( | ||||
| 		const editor = await createTestEditor( | ||||
| 			initialDocText, EditorSelection.cursor(preSubListText.length + 3), | ||||
| 			['BlockQuote', 'BulletList'], | ||||
| 		); | ||||
| @@ -2,7 +2,7 @@ | ||||
| 
 | ||||
| import { EditorView, Command } from '@codemirror/view'; | ||||
| 
 | ||||
| import { ListType } from '../types'; | ||||
| import { ListType } from '../../types'; | ||||
| import { | ||||
| 	SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec, | ||||
| } from '@codemirror/state'; | ||||
| @@ -3,11 +3,11 @@ import { SyntaxNode } from '@lezer/common'; | ||||
| import { EditorSelection, EditorState } from '@codemirror/state'; | ||||
| import { blockMathTagName, inlineMathContentTagName, inlineMathTagName } from './markdownMathParser'; | ||||
| 
 | ||||
| import createEditor from './testUtil/createEditor'; | ||||
| import createTestEditor from '../testUtil/createTestEditor'; | ||||
| 
 | ||||
| // Creates an EditorState with math and markdown extensions
 | ||||
| const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => { | ||||
| 	return (await createEditor(initialText, EditorSelection.cursor(0), expectedTags)).state; | ||||
| 	return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state; | ||||
| }; | ||||
| 
 | ||||
| // Returns a list of all nodes with the given name in the given editor's syntax tree.
 | ||||
							
								
								
									
										24
									
								
								packages/editor/CodeMirror/testUtil/createEditorSettings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								packages/editor/CodeMirror/testUtil/createEditorSettings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import { EditorKeymap, EditorLanguageType, EditorSettings } from '../../types'; | ||||
|  | ||||
| const createEditorSettings = (themeId: number) => { | ||||
| 	const themeData = themeStyle(themeId); | ||||
| 	const editorSettings: EditorSettings = { | ||||
| 		katexEnabled: true, | ||||
| 		spellcheckEnabled: true, | ||||
| 		useExternalSearch: true, | ||||
| 		readOnly: false, | ||||
| 		automatchBraces: false, | ||||
| 		ignoreModifiers: false, | ||||
|  | ||||
| 		keymap: EditorKeymap.Default, | ||||
| 		language: EditorLanguageType.Markdown, | ||||
| 		themeData, | ||||
|  | ||||
| 		indentWithTabs: true, | ||||
| 	}; | ||||
|  | ||||
| 	return editorSettings; | ||||
| }; | ||||
|  | ||||
| export default createEditorSettings; | ||||
| @@ -3,13 +3,13 @@ import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown'; | ||||
| import { indentUnit, syntaxTree } from '@codemirror/language'; | ||||
| import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state'; | ||||
| import { EditorView } from '@codemirror/view'; | ||||
| import { MarkdownMathExtension } from '../markdownMathParser'; | ||||
| import { MarkdownMathExtension } from '../markdown/markdownMathParser'; | ||||
| import forceFullParse from './forceFullParse'; | ||||
| import loadLangauges from './loadLanguages'; | ||||
| 
 | ||||
| // Creates and returns a minimal editor with markdown extensions. Waits to return the editor
 | ||||
| // until all syntax tree tags in `expectedSyntaxTreeTags` exist.
 | ||||
| const createEditor = async ( | ||||
| const createTestEditor = async ( | ||||
| 	initialText: string, initialSelection: SelectionRange, expectedSyntaxTreeTags: string[], | ||||
| ): Promise<EditorView> => { | ||||
| 	await loadLangauges(); | ||||
| @@ -62,4 +62,4 @@ const createEditor = async ( | ||||
| 	return editor; | ||||
| }; | ||||
| 
 | ||||
| export default createEditor; | ||||
| export default createTestEditor; | ||||
| @@ -1,4 +1,4 @@ | ||||
| import syntaxHighlightingLanguages from '../syntaxHighlightingLanguages'; | ||||
| import syntaxHighlightingLanguages from '../markdown/syntaxHighlightingLanguages'; | ||||
| 
 | ||||
| // Ensure languages we use are loaded. Without this, tests may randomly fail (LanguageDescriptions
 | ||||
| // are loaded asyncronously, in the background).
 | ||||
| @@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight'; | ||||
| import { EditorView } from '@codemirror/view'; | ||||
| import { Extension } from '@codemirror/state'; | ||||
| 
 | ||||
| import { inlineMathTag, mathTag } from './markdownMathParser'; | ||||
| import { inlineMathTag, mathTag } from './markdown/markdownMathParser'; | ||||
| 
 | ||||
| // For an example on how to customize the theme, see:
 | ||||
| //
 | ||||
| @@ -25,6 +25,12 @@ import { inlineMathTag, mathTag } from './markdownMathParser'; | ||||
| //
 | ||||
| // [theme] should be a joplin theme (see @joplin/lib/theme)
 | ||||
| const createTheme = (theme: any): Extension[] => { | ||||
| 	// If the theme hasn't loaded yet, return nothing.
 | ||||
| 	// (createTheme should be called again after the theme has loaded).
 | ||||
| 	if (!theme) { | ||||
| 		return []; | ||||
| 	} | ||||
| 
 | ||||
| 	const isDarkTheme = theme.appearance === 'dark'; | ||||
| 
 | ||||
| 	const baseGlobalStyle: Record<string, string> = { | ||||
| @@ -34,15 +40,19 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 		// On iOS, apply system font scaling (e.g. font scaling
 | ||||
| 		// set in accessibility settings).
 | ||||
| 		font: '-apple-system-body', | ||||
| 
 | ||||
| 		// Fill container horizontally
 | ||||
| 		width: '100%', | ||||
| 		boxSizing: 'border-box', | ||||
| 	}; | ||||
| 	const baseCursorStyle: Record<string, string> = { }; | ||||
| 	const baseContentStyle: Record<string, string> = { | ||||
| 		fontFamily: theme.fontFamily, | ||||
| 		fontSize: `${theme.fontSize}${theme.fontSizeUnits ?? 'px'}`, | ||||
| 
 | ||||
| 		// To allow accessibility font scaling, we also need to set the
 | ||||
| 		// fontSize to a value in `em`s (relative scaling relative to
 | ||||
| 		// parent font size).
 | ||||
| 		fontSize: `${theme.fontSize}em`, | ||||
| 		// Avoid using units here -- 1.55em, for example, can cause lines to overlap
 | ||||
| 		// if some lines contain text with a large enough font size.
 | ||||
| 		lineHeight: theme.isDesktop ? '1.55' : undefined, | ||||
| 	}; | ||||
| 	const baseSelectionStyle: Record<string, string> = { }; | ||||
| 	const blurredSelectionStyle: Record<string, string> = { }; | ||||
| @@ -60,17 +70,42 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 		blurredSelectionStyle.backgroundColor = '#444'; | ||||
| 	} | ||||
| 
 | ||||
| 	const baseTheme = EditorView.baseTheme({ | ||||
| 	const monospaceStyle = { | ||||
| 		fontFamily: theme.monospaceFont || 'monospace', | ||||
| 	}; | ||||
| 
 | ||||
| 	// This is equivalent to the default selection style -- our styling must
 | ||||
| 	// be at least this specific.
 | ||||
| 	const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground'; | ||||
| 
 | ||||
| 	const codeMirrorTheme = EditorView.theme({ | ||||
| 		'&': baseGlobalStyle, | ||||
| 
 | ||||
| 		// These must be !important or more specific than CodeMirror's built-ins
 | ||||
| 		'.cm-content': { | ||||
| 			fontFamily: theme.fontFamily, | ||||
| 			...baseContentStyle, | ||||
| 			paddingBottom: theme.isDesktop ? '400px' : undefined, | ||||
| 		}, | ||||
| 		'&.cm-focused .cm-cursor': baseCursorStyle, | ||||
| 		'&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle, | ||||
| 		'.cm-selectionBackground': blurredSelectionStyle, | ||||
| 
 | ||||
| 		// The desktop app sets the font for these elements to a specific font.
 | ||||
| 		// Override this.
 | ||||
| 		'& div, & span, & a': { | ||||
| 			fontFamily: 'inherit', | ||||
| 		}, | ||||
| 
 | ||||
| 		// Override the default border around CodeMirror panels
 | ||||
| 		'& > .cm-panels': { | ||||
| 			border: 'none', | ||||
| 		}, | ||||
| 
 | ||||
| 		// &.cm-focused is used to give these styles higher specificity
 | ||||
| 		// than the defaults.
 | ||||
| 		[selectionBackgroundSelector]: baseSelectionStyle, | ||||
| 		'&.cm-focused ::selection': baseSelectionStyle, | ||||
| 		'& ::selection': blurredSelectionStyle, | ||||
| 		'& .cm-selectionLayer .cm-selectionBackground': blurredSelectionStyle, | ||||
| 
 | ||||
| 		'&.cm-editor.cm-focused': { | ||||
| 			outline: 'none !important', | ||||
| @@ -101,6 +136,8 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 			borderStyle: 'solid', | ||||
| 			borderColor: theme.colorFaded, | ||||
| 			backgroundColor: 'rgba(155, 155, 155, 0.1)', | ||||
| 
 | ||||
| 			...(theme.isDesktop ? monospaceStyle : {}), | ||||
| 		}, | ||||
| 
 | ||||
| 		// CodeMirror wraps the existing inline span in an additional element.
 | ||||
| @@ -113,12 +150,21 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 			borderStyle: 'solid', | ||||
| 			borderColor: isDarkTheme ? 'rgba(200, 200, 200, 0.5)' : 'rgba(100, 100, 100, 0.5)', | ||||
| 			borderRadius: '4px', | ||||
| 
 | ||||
| 			...(theme.isDesktop ? monospaceStyle : {}), | ||||
| 		}, | ||||
| 
 | ||||
| 		'& .cm-mathBlock, & .cm-inlineMath': { | ||||
| 			color: isDarkTheme ? '#9fa' : '#276', | ||||
| 		}, | ||||
| 
 | ||||
| 		'& .cm-tableHeader, & .cm-tableRow, & .cm-tableDelimiter': monospaceStyle, | ||||
| 		'& .cm-taskMarker': monospaceStyle, | ||||
| 
 | ||||
| 		// Override the default URL style when the URL is within a link
 | ||||
| 		'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': { | ||||
| 			opacity: theme.isDesktop ? 0.6 : 1, | ||||
| 		}, | ||||
| 
 | ||||
| 		// Style the search widget. Use ':root' to increase the selector's precedence
 | ||||
| 		// (override the existing preset styles).
 | ||||
| @@ -128,9 +174,7 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 				color: isDarkTheme ? 'white' : 'black', | ||||
| 			}, | ||||
| 		}, | ||||
| 	}); | ||||
| 
 | ||||
| 	const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme }); | ||||
| 	}, { dark: isDarkTheme }); | ||||
| 
 | ||||
| 	const baseHeadingStyle = { | ||||
| 		fontWeight: 'bold', | ||||
| @@ -150,7 +194,6 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 			...baseHeadingStyle, | ||||
| 			tag: tags.heading1, | ||||
| 			fontSize: '1.6em', | ||||
| 			borderBottom: `1px solid ${theme.dividerColor}`, | ||||
| 		}, | ||||
| 		{ | ||||
| 			...baseHeadingStyle, | ||||
| @@ -189,7 +232,7 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 		{ | ||||
| 			tag: tags.link, | ||||
| 			color: theme.urlColor, | ||||
| 			textDecoration: 'underline', | ||||
| 			textDecoration: theme.isDesktop ? undefined : 'underline', | ||||
| 		}, | ||||
| 		{ | ||||
| 			tag: [mathTag, inlineMathTag], | ||||
| @@ -220,8 +263,7 @@ const createTheme = (theme: any): Extension[] => { | ||||
| 	]); | ||||
| 
 | ||||
| 	return [ | ||||
| 		baseTheme, | ||||
| 		appearanceTheme, | ||||
| 		codeMirrorTheme, | ||||
| 		syntaxHighlighting(highlightingStyle), | ||||
| 
 | ||||
| 		// If we haven't defined highlighting for tags, fall back
 | ||||
							
								
								
									
										73
									
								
								packages/editor/SelectionFormatting.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								packages/editor/SelectionFormatting.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| // Stores information about the current content of the user's selection | ||||
| export interface MutableSelectionFormatting { | ||||
| 	bolded: boolean; | ||||
| 	italicized: boolean; | ||||
| 	inChecklist: boolean; | ||||
| 	inCode: boolean; | ||||
| 	inUnorderedList: boolean; | ||||
| 	inOrderedList: boolean; | ||||
| 	inMath: boolean; | ||||
| 	inLink: boolean; | ||||
| 	spellChecking: boolean; | ||||
| 	unspellCheckableRegion: boolean; | ||||
|  | ||||
| 	// Link data, both fields are null if not in a link. | ||||
| 	linkData: { | ||||
| 		readonly linkText: string|null; | ||||
| 		readonly linkURL: string|null; | ||||
| 	}; | ||||
|  | ||||
| 	// If [headerLevel], [listLevel], etc. are zero, then the | ||||
| 	// selection isn't in a header/list | ||||
| 	headerLevel: number; | ||||
| 	listLevel: number; | ||||
|  | ||||
| 	// Content of the selection | ||||
| 	selectedText: string; | ||||
| } | ||||
| type SelectionFormatting = Readonly<MutableSelectionFormatting>; | ||||
| export default SelectionFormatting; | ||||
|  | ||||
| export const defaultSelectionFormatting: SelectionFormatting = { | ||||
| 	bolded: false, | ||||
| 	italicized: false, | ||||
| 	inChecklist: false, | ||||
| 	inCode: false, | ||||
| 	inUnorderedList: false, | ||||
| 	inOrderedList: false, | ||||
| 	inMath: false, | ||||
| 	inLink: false, | ||||
| 	spellChecking: false, | ||||
| 	unspellCheckableRegion: false, | ||||
|  | ||||
| 	linkData: { | ||||
| 		linkText: null, | ||||
| 		linkURL: null, | ||||
| 	}, | ||||
|  | ||||
| 	headerLevel: 0, | ||||
| 	listLevel: 0, | ||||
|  | ||||
| 	selectedText: '', | ||||
| }; | ||||
|  | ||||
| export const selectionFormattingEqual = (a: SelectionFormatting, b: SelectionFormatting): boolean => { | ||||
| 	// Get keys from the default so that only SelectionFormatting key/value pairs are | ||||
| 	// considered. If a and/or b inherit from SelectionFormatting, we want to ignore | ||||
| 	// keys added by child interfaces. | ||||
| 	const keys = Object.keys(defaultSelectionFormatting) as (keyof SelectionFormatting)[]; | ||||
|  | ||||
| 	for (const key of keys) { | ||||
| 		if (key === 'linkData') { | ||||
| 			// A deeper check is required for linkData | ||||
| 			if (a[key].linkText !== b[key].linkText || a[key].linkURL !== b[key].linkURL) { | ||||
| 				return false; | ||||
| 			} | ||||
| 		} else if (a[key] !== b[key]) { | ||||
| 			return false; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return true; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										65
									
								
								packages/editor/events.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								packages/editor/events.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| import type SelectionFormatting from './SelectionFormatting'; | ||||
| import type { SearchState } from './types'; | ||||
|  | ||||
| export enum EditorEventType { | ||||
| 	Change, | ||||
| 	UndoRedoDepthChange, | ||||
| 	SelectionRangeChange, | ||||
| 	SelectionFormattingChange, | ||||
| 	UpdateSearchDialog, | ||||
| 	EditLink, | ||||
| 	Scroll, | ||||
| } | ||||
|  | ||||
| export interface ChangeEvent { | ||||
| 	kind: EditorEventType.Change; | ||||
|  | ||||
| 	// New editor content | ||||
| 	value: string; | ||||
| } | ||||
|  | ||||
| export interface UndoRedoDepthChangeEvent { | ||||
| 	kind: EditorEventType.UndoRedoDepthChange; | ||||
|  | ||||
| 	undoDepth: number; | ||||
| 	redoDepth: number; | ||||
| } | ||||
|  | ||||
| export interface SelectionRangeChangeEvent { | ||||
| 	kind: EditorEventType.SelectionRangeChange; | ||||
|  | ||||
| 	anchor: number; | ||||
| 	head: number; | ||||
|  | ||||
| 	from: number; | ||||
| 	to: number; | ||||
| } | ||||
|  | ||||
| export interface SelectionFormattingChangeEvent { | ||||
| 	kind: EditorEventType.SelectionFormattingChange; | ||||
| 	formatting: SelectionFormatting; | ||||
| } | ||||
|  | ||||
| export interface EditorScrolledEvent { | ||||
| 	kind: EditorEventType.Scroll; | ||||
|  | ||||
| 	// A fraction from 0 to 1, where 1 corresponds to the end of the document | ||||
| 	fraction: number; | ||||
| } | ||||
|  | ||||
| export interface UpdateSearchDialogEvent { | ||||
| 	kind: EditorEventType.UpdateSearchDialog; | ||||
| 	searchState: SearchState; | ||||
| } | ||||
|  | ||||
| export interface RequestEditLinkEvent { | ||||
| 	kind: EditorEventType.EditLink; | ||||
| } | ||||
|  | ||||
|  | ||||
| export type EditorEvent = | ||||
| 		ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent| | ||||
| 			EditorScrolledEvent| | ||||
| 			SelectionFormattingChangeEvent|UpdateSearchDialogEvent| | ||||
| 			RequestEditLinkEvent; | ||||
|  | ||||
							
								
								
									
										19
									
								
								packages/editor/jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/editor/jest.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| module.exports = { | ||||
| 	'moduleFileExtensions': [ | ||||
| 		'ts', | ||||
| 		'tsx', | ||||
| 		'js', | ||||
| 		'jsx', | ||||
| 	], | ||||
|  | ||||
| 	'transform': { | ||||
| 		'\\.(ts|tsx)$': 'ts-jest', | ||||
| 	}, | ||||
|  | ||||
| 	testEnvironment: 'jsdom', | ||||
| 	testMatch: ['**/*.test.(ts|tsx)'], | ||||
| 	setupFilesAfterEnv: ['./jest.setup.js'], | ||||
|  | ||||
| 	testPathIgnorePatterns: ['<rootDir>/node_modules/'], | ||||
| 	slowTestThreshold: 40, | ||||
| }; | ||||
							
								
								
									
										16
									
								
								packages/editor/jest.setup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								packages/editor/jest.setup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
|  | ||||
| // Prevents the CodeMirror error "getClientRects is undefined". | ||||
| // See https://github.com/jsdom/jsdom/issues/3002#issue-652790925 | ||||
| document.createRange = () => { | ||||
| 	const range = new Range(); | ||||
| 	range.getBoundingClientRect = jest.fn(); | ||||
| 	range.getClientRects = () => { | ||||
| 		return { | ||||
| 			length: 0, | ||||
| 			item: () => null, | ||||
| 			[Symbol.iterator]: jest.fn(), | ||||
| 		}; | ||||
| 	}; | ||||
|  | ||||
| 	return range; | ||||
| }; | ||||
							
								
								
									
										48
									
								
								packages/editor/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/editor/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| { | ||||
| 	"name": "@joplin/editor", | ||||
| 	"version": "2.13.0", | ||||
| 	"description": "Web-based markdown editor", | ||||
| 	"private": true, | ||||
| 	"scripts": { | ||||
| 		"tsc": "tsc --project tsconfig.json", | ||||
| 		"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json", | ||||
| 		"postinstall": "yarn run tsc", | ||||
| 		"test": "jest", | ||||
| 		"test-ci": "yarn test" | ||||
| 	}, | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
| 		"url": "git+https://github.com/laurent22/joplin.git" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@joplin/lib": "~2.13", | ||||
| 		"@joplin/tools": "~2.13", | ||||
| 		"@testing-library/react-hooks": "8.0.1", | ||||
| 		"@types/jest": "29.5.3", | ||||
| 		"@types/react": "18.0.24", | ||||
| 		"@types/react-redux": "7.1.25", | ||||
| 		"@types/styled-components": "5.1.26", | ||||
| 		"jest": "29.5.0", | ||||
| 		"jest-environment-jsdom": "29.5.0", | ||||
| 		"ts-jest": "29.1.1", | ||||
| 		"typescript": "5.1.3" | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"@codemirror/autocomplete": "6.9.0", | ||||
| 		"@codemirror/commands": "6.2.5", | ||||
| 		"@codemirror/lang-cpp": "6.0.2", | ||||
| 		"@codemirror/lang-html": "6.4.6", | ||||
| 		"@codemirror/lang-java": "6.0.1", | ||||
| 		"@codemirror/lang-javascript": "6.2.1", | ||||
| 		"@codemirror/lang-markdown": "6.2.1", | ||||
| 		"@codemirror/lang-php": "6.0.1", | ||||
| 		"@codemirror/lang-rust": "6.0.1", | ||||
| 		"@codemirror/language": "6.9.0", | ||||
| 		"@codemirror/legacy-modes": "6.3.3", | ||||
| 		"@codemirror/search": "6.5.2", | ||||
| 		"@codemirror/state": "6.2.1", | ||||
| 		"@codemirror/view": "6.18.0", | ||||
| 		"@lezer/markdown": "1.1.0", | ||||
| 		"@replit/codemirror-vim": "6.0.14" | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										11
									
								
								packages/editor/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/editor/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| { | ||||
| 	"extends": "../../tsconfig.json", | ||||
| 	"include": [ | ||||
| 		"**/*.ts", | ||||
| 		"**/*.tsx" | ||||
| 	], | ||||
| 	"exclude": [ | ||||
| 		"**/node_modules", | ||||
| 		"**/dist" | ||||
| 	] | ||||
| } | ||||
							
								
								
									
										163
									
								
								packages/editor/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								packages/editor/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| import type { Theme } from '@joplin/lib/themes/type'; | ||||
| import type { EditorEvent } from './events'; | ||||
|  | ||||
| // Editor commands. For compatibility, the string values of these commands | ||||
| // should correspond with the CodeMirror 5 commands: | ||||
| // https://codemirror.net/5/doc/manual.html#commands | ||||
| export enum EditorCommandType { | ||||
| 	Undo = 'undo', | ||||
| 	Redo = 'redo', | ||||
| 	SelectAll = 'selectAll', | ||||
| 	Focus = 'focus', | ||||
|  | ||||
| 	// Formatting editor commands | ||||
| 	ToggleBolded = 'textBold', | ||||
| 	ToggleItalicized = 'textItalic', | ||||
| 	ToggleCode = 'textCode', | ||||
| 	ToggleMath = 'textMath', | ||||
|  | ||||
| 	ToggleNumberedList = 'textNumberedList', | ||||
| 	ToggleBulletedList = 'textBulletedList', | ||||
| 	ToggleCheckList = 'textCheckbox', | ||||
|  | ||||
| 	ToggleHeading = 'textHeading', | ||||
| 	ToggleHeading1 = 'textHeading1', | ||||
| 	ToggleHeading2 = 'textHeading2', | ||||
| 	ToggleHeading3 = 'textHeading3', | ||||
| 	ToggleHeading4 = 'textHeading4', | ||||
| 	ToggleHeading5 = 'textHeading5', | ||||
|  | ||||
| 	// Find commands | ||||
| 	ShowSearch = 'find', | ||||
| 	HideSearch = 'hideSearchDialog', | ||||
| 	FindNext = 'findNext', | ||||
| 	FindPrevious = 'findPrev', | ||||
| 	ReplaceNext = 'replace', | ||||
| 	ReplaceAll = 'replaceAll', | ||||
|  | ||||
| 	// Editing and navigation commands | ||||
| 	ScrollSelectionIntoView = 'scrollSelectionIntoView', | ||||
| 	DeleteToLineEnd = 'killLine', | ||||
| 	DeleteToLineStart = 'delLineLeft', | ||||
| 	IndentMore = 'indentMore', | ||||
| 	IndentLess = 'indentLess', | ||||
| 	IndentAuto = 'indentAuto', | ||||
| 	InsertNewlineAndIndent = 'newlineAndIndent', | ||||
|  | ||||
| 	SwapLineUp = 'swapLineUp', | ||||
| 	SwapLineDown = 'swapLineDown', | ||||
|  | ||||
| 	GoDocEnd = 'goDocEnd', | ||||
| 	GoDocStart = 'goDocStart', | ||||
| 	GoLineStart = 'goLineStart', | ||||
| 	GoLineEnd = 'goLineEnd', | ||||
| 	GoLineUp = 'goLineUp', | ||||
| 	GoLineDown = 'goLineDown', | ||||
| 	GoPageUp = 'goPageUp', | ||||
| 	GoPageDown = 'goPageDown', | ||||
| 	GoCharLeft = 'goCharLeft', | ||||
| 	GoCharRight = 'goCharRight', | ||||
|  | ||||
| 	UndoSelection = 'undoSelection', | ||||
| 	RedoSelection = 'redoSelection', | ||||
| } | ||||
|  | ||||
| // Because the editor package can run in a WebView, plugin content scripts | ||||
| // need to be provided as text, rather than as file paths. | ||||
| export interface PluginData { | ||||
| 	pluginId: string; | ||||
| 	contentScriptId: string; | ||||
| 	contentScriptJs: ()=> Promise<string>; | ||||
| 	postMessageHandler: (message: any)=> any; | ||||
| } | ||||
|  | ||||
| export interface EditorControl { | ||||
| 	supportsCommand(name: EditorCommandType|string): boolean; | ||||
| 	execCommand(name: EditorCommandType|string): void; | ||||
|  | ||||
| 	undo(): void; | ||||
| 	redo(): void; | ||||
|  | ||||
| 	select(anchor: number, head: number): void; | ||||
|  | ||||
| 	// 0 corresponds to the top, 1 corresponds to the bottom. | ||||
| 	setScrollPercent(fraction: number): void; | ||||
|  | ||||
| 	insertText(text: string): void; | ||||
| 	updateBody(newBody: string): void; | ||||
|  | ||||
| 	updateSettings(newSettings: EditorSettings): void; | ||||
|  | ||||
| 	// Create a new link or update the currently selected link with | ||||
| 	// the given [label] and [url]. | ||||
| 	updateLink(label: string, url: string): void; | ||||
|  | ||||
| 	setSearchState(state: SearchState): void; | ||||
|  | ||||
| 	setPlugins(plugins: PluginData[]): Promise<void>; | ||||
| } | ||||
|  | ||||
| export enum EditorLanguageType { | ||||
| 	Markdown, | ||||
| 	Html, | ||||
| } | ||||
|  | ||||
| export enum EditorKeymap { | ||||
| 	Default = 'default', | ||||
| 	Vim = 'vim', | ||||
| 	Emacs = 'emacs', | ||||
| } | ||||
|  | ||||
| export interface EditorSettings { | ||||
| 	// EditorSettings objects are deserialized within WebViews, where | ||||
| 	// [themeStyle(themeId: number)] doesn't work. As such, we need both | ||||
| 	// a Theme must be provided. | ||||
| 	themeData: Theme; | ||||
|  | ||||
| 	// True if the search panel is implemented outside of the editor (e.g. with | ||||
| 	// React Native). | ||||
| 	useExternalSearch: boolean; | ||||
|  | ||||
| 	automatchBraces: boolean; | ||||
|  | ||||
| 	// True if internal command keyboard shortcuts should be ignored (thus | ||||
| 	// allowing Joplin shortcuts to run). | ||||
| 	ignoreModifiers: boolean; | ||||
|  | ||||
| 	language: EditorLanguageType; | ||||
|  | ||||
| 	keymap: EditorKeymap; | ||||
|  | ||||
| 	katexEnabled: boolean; | ||||
| 	spellcheckEnabled: boolean; | ||||
| 	readOnly: boolean; | ||||
|  | ||||
| 	indentWithTabs: boolean; | ||||
| } | ||||
|  | ||||
| export type LogMessageCallback = (message: string)=> void; | ||||
| export type OnEventCallback = (event: EditorEvent)=> void; | ||||
|  | ||||
| export interface EditorProps { | ||||
| 	settings: EditorSettings; | ||||
| 	initialText: string; | ||||
|  | ||||
| 	onEvent: OnEventCallback; | ||||
| 	onLogMessage: LogMessageCallback; | ||||
| } | ||||
|  | ||||
| export interface SearchState { | ||||
| 	useRegex: boolean; | ||||
| 	caseSensitive: boolean; | ||||
|  | ||||
| 	searchText: string; | ||||
| 	replaceText: string; | ||||
| 	dialogVisible: boolean; | ||||
| } | ||||
|  | ||||
| // Possible types of lists in the editor | ||||
| export enum ListType { | ||||
| 	CheckList, | ||||
| 	OrderedList, | ||||
| 	UnorderedList, | ||||
| } | ||||
| @@ -1022,23 +1022,6 @@ class Setting extends BaseModel { | ||||
| 			'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, | ||||
| 			trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') }, | ||||
|  | ||||
| 			// 2020-10-29: For now disable the beta editor due to | ||||
| 			// underlying bugs in the TextInput component which we cannot | ||||
| 			// fix. Also the editor crashes in Android and in some cases in | ||||
| 			// iOS. | ||||
| 			// https://discourse.joplinapp.org/t/anyone-using-the-beta-editor-on-ios/11658/9 | ||||
| 			'editor.beta': { | ||||
| 				value: false, | ||||
| 				type: SettingItemType.Bool, | ||||
| 				section: 'note', | ||||
| 				public: false, | ||||
| 				appTypes: [AppType.Mobile], | ||||
| 				label: () => 'Opt-in to the editor beta', | ||||
| 				description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.', | ||||
| 				storage: SettingStorage.File, | ||||
| 				isGlobal: true, | ||||
| 			}, | ||||
|  | ||||
| 			'editor.usePlainText': { | ||||
| 				value: false, | ||||
| 				type: SettingItemType.Bool, | ||||
| @@ -1475,6 +1458,20 @@ class Setting extends BaseModel { | ||||
| 				isGlobal: true, | ||||
| 			}, | ||||
|  | ||||
| 			// 2023-09-07: This setting is now used to track the desktop beta editor. It | ||||
| 			// was used to track the mobile beta editor previously. | ||||
| 			'editor.beta': { | ||||
| 				value: false, | ||||
| 				type: SettingItemType.Bool, | ||||
| 				section: 'general', | ||||
| 				public: true, | ||||
| 				appTypes: [AppType.Desktop], | ||||
| 				label: () => 'Opt-in to the editor beta', | ||||
| 				description: () => 'This beta adds improved accessibility and plugin API compatibility with the mobile editor. If you find bugs, please report them in the Discourse forum.', | ||||
| 				storage: SettingStorage.File, | ||||
| 				isGlobal: true, | ||||
| 			}, | ||||
|  | ||||
| 			'net.customCertificates': { | ||||
| 				value: '', | ||||
| 				type: SettingItemType.String, | ||||
|   | ||||
							
								
								
									
										425
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										425
									
								
								yarn.lock
									
									
									
									
									
								
							| @@ -2778,13 +2778,13 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@bam.tech/react-native-image-resizer@npm:3.0.7": | ||||
|   version: 3.0.7 | ||||
|   resolution: "@bam.tech/react-native-image-resizer@npm:3.0.7" | ||||
| "@bam.tech/react-native-image-resizer@npm:3.0.5": | ||||
|   version: 3.0.5 | ||||
|   resolution: "@bam.tech/react-native-image-resizer@npm:3.0.5" | ||||
|   peerDependencies: | ||||
|     react: "*" | ||||
|     react-native: "*" | ||||
|   checksum: a4eaeb4a00fcc92e9f31d2e7bd7a55f0f90a76b25f8cd049e087ed95b99d961481bb0c5baad6393b57a551266ade10cacd608b37e27fc458a120c04b947729a6 | ||||
|   checksum: f555bd10aafab1a797b6f5142e59b1bb11f229b61d35c3e6c9d734bbd4999f97bcc86853f69965d758ca872aad024add7a852041da498b177eee923ef82e506d | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -2814,9 +2814,9 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/autocomplete@npm:^6.0.0": | ||||
|   version: 6.4.2 | ||||
|   resolution: "@codemirror/autocomplete@npm:6.4.2" | ||||
| "@codemirror/autocomplete@npm:6.9.0, @codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.7.1": | ||||
|   version: 6.9.0 | ||||
|   resolution: "@codemirror/autocomplete@npm:6.9.0" | ||||
|   dependencies: | ||||
|     "@codemirror/language": ^6.0.0 | ||||
|     "@codemirror/state": ^6.0.0 | ||||
| @@ -2827,19 +2827,19 @@ __metadata: | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     "@lezer/common": ^1.0.0 | ||||
|   checksum: c6cc4edb1c412153e6f6f27926674d7f1d386d1f30d6d4f60c5b52bfa0105870b0c70449b69891937bcf082340d8b0fa6d1f9f28f5eb60adc2974ed4c73aadc1 | ||||
|   checksum: a5f661944c75f40b02c90a193c9a459c0fd7e335c0ac5973420c19157dfb46010f573c2b70731591fe477e7a2ad10121ff3ae394a72d450946d7b886c28b0368 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/commands@npm:6.2.2": | ||||
|   version: 6.2.2 | ||||
|   resolution: "@codemirror/commands@npm:6.2.2" | ||||
| "@codemirror/commands@npm:6.2.5": | ||||
|   version: 6.2.5 | ||||
|   resolution: "@codemirror/commands@npm:6.2.5" | ||||
|   dependencies: | ||||
|     "@codemirror/language": ^6.0.0 | ||||
|     "@codemirror/state": ^6.2.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     "@lezer/common": ^1.0.0 | ||||
|   checksum: d3aa1ca8cbd7b9434eedba6b6d783411670796bf6ab61990afc4fd0c04645189fe4dd55bb95e23b943e9089f9739bc7e92aa4b2ac3eac09cfa2b91a45f608d3e | ||||
|   checksum: 6d373bcfd4337160243e1493c8703a8e367e208811742331679a6410a3645de36ae8a5664e11790fec521137b45f34d703e9292932a98c4de10139510f3f29a3 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -2854,31 +2854,32 @@ __metadata: | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/lang-css@npm:^6.0.0": | ||||
|   version: 6.1.1 | ||||
|   resolution: "@codemirror/lang-css@npm:6.1.1" | ||||
|   version: 6.2.1 | ||||
|   resolution: "@codemirror/lang-css@npm:6.2.1" | ||||
|   dependencies: | ||||
|     "@codemirror/autocomplete": ^6.0.0 | ||||
|     "@codemirror/language": ^6.0.0 | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@lezer/common": ^1.0.2 | ||||
|     "@lezer/css": ^1.0.0 | ||||
|   checksum: 9b0bf7c7544fb604b67325689d783981e4099560f577bc1f10c52cb18e9d275ebdbdbd3f335a1dbb9c4910c36320f74ca015fc92ef99f930ecb9d481a2bf3511 | ||||
|   checksum: 5a8457ee8a4310030a969f2d3128429f549c4dc9b7907ee8888b42119c80b65af99093801432efdf659b8ec36a147d2a947bc1ecbbf69a759395214e3f4834a8 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/lang-html@npm:6.4.3, @codemirror/lang-html@npm:^6.0.0": | ||||
|   version: 6.4.3 | ||||
|   resolution: "@codemirror/lang-html@npm:6.4.3" | ||||
| "@codemirror/lang-html@npm:6.4.6, @codemirror/lang-html@npm:^6.0.0": | ||||
|   version: 6.4.6 | ||||
|   resolution: "@codemirror/lang-html@npm:6.4.6" | ||||
|   dependencies: | ||||
|     "@codemirror/autocomplete": ^6.0.0 | ||||
|     "@codemirror/lang-css": ^6.0.0 | ||||
|     "@codemirror/lang-javascript": ^6.0.0 | ||||
|     "@codemirror/language": ^6.4.0 | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.2.2 | ||||
|     "@codemirror/view": ^6.17.0 | ||||
|     "@lezer/common": ^1.0.0 | ||||
|     "@lezer/css": ^1.1.0 | ||||
|     "@lezer/html": ^1.3.0 | ||||
|   checksum: 6177d19147580964ecd6910ae951201929a96e63f4f0e624c3138e2805fa87ec6d6d952a3a888c5a52af78b6dd6d04d7d8c76c6a9cd65b1921dc467b5dbaea72 | ||||
|   checksum: 8f884f4423ffc783181ee933f7212ad4ece204695cf8af9535a593f95e901d36515a8561fc336a0fbcf5782369b9484eeb0d2cec2167622868238177c5e6eb36 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -2892,32 +2893,33 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/lang-javascript@npm:6.1.5, @codemirror/lang-javascript@npm:^6.0.0": | ||||
|   version: 6.1.5 | ||||
|   resolution: "@codemirror/lang-javascript@npm:6.1.5" | ||||
| "@codemirror/lang-javascript@npm:6.2.1, @codemirror/lang-javascript@npm:^6.0.0": | ||||
|   version: 6.2.1 | ||||
|   resolution: "@codemirror/lang-javascript@npm:6.2.1" | ||||
|   dependencies: | ||||
|     "@codemirror/autocomplete": ^6.0.0 | ||||
|     "@codemirror/language": ^6.6.0 | ||||
|     "@codemirror/lint": ^6.0.0 | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     "@codemirror/view": ^6.17.0 | ||||
|     "@lezer/common": ^1.0.0 | ||||
|     "@lezer/javascript": ^1.0.0 | ||||
|   checksum: f0355f9577fac03437137356b5c8826ec073480d9b0efc62289eac483172d47dafe569f31bf788e4228e8b789197e50a0768cf10b0cde5f600e89b6b469f52cc | ||||
|   checksum: 3df38c4cced06195283a9a2a9365aaa7c8c1b157852b331bc3a118403f774bbba57d2a392de52f5e28d2b344a323bc0146bcf7c8ef8be2473f167d815e4a37cd | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/lang-markdown@npm:6.1.0": | ||||
|   version: 6.1.0 | ||||
|   resolution: "@codemirror/lang-markdown@npm:6.1.0" | ||||
| "@codemirror/lang-markdown@npm:6.2.1": | ||||
|   version: 6.2.1 | ||||
|   resolution: "@codemirror/lang-markdown@npm:6.2.1" | ||||
|   dependencies: | ||||
|     "@codemirror/autocomplete": ^6.7.1 | ||||
|     "@codemirror/lang-html": ^6.0.0 | ||||
|     "@codemirror/language": ^6.3.0 | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     "@lezer/common": ^1.0.0 | ||||
|     "@lezer/markdown": ^1.0.0 | ||||
|   checksum: faee880c5e695391fc5b92788d1500bed3f0cc3766c987077cdc1643cf38b97eb1774a29491a7a75064089478b895e7c8fe5a4f08ac93c9614ccbbe188f10b47 | ||||
|   checksum: ef3bdfd01e418efc7f7fdf0baa2e8e91875b37f870fcad98f846954763c7cc71bac95736591cd6c52b39cc380261d76ae7b37ca97ef1641c4c266476748046d3 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -2944,9 +2946,9 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/language@npm:6.6.0, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0": | ||||
|   version: 6.6.0 | ||||
|   resolution: "@codemirror/language@npm:6.6.0" | ||||
| "@codemirror/language@npm:6.9.0, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0": | ||||
|   version: 6.9.0 | ||||
|   resolution: "@codemirror/language@npm:6.9.0" | ||||
|   dependencies: | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
| @@ -2954,56 +2956,56 @@ __metadata: | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|     style-mod: ^4.0.0 | ||||
|   checksum: bb9411620e2f231653a3f0c4429e0d19a3843bff5dbc117df4649d7bf783ec4ad809c0add8bc0887a4ec3f48b4f8f941621168e47d76101d5383f0d670af1722 | ||||
|   checksum: 9a897fb0f569159eeafb7dce83061b425af7244bbeae2649e0e677488548b2a02eaf0c13c0c5b4d59da55e8866e6f4dc7abe3dfaa09c13749a2fa2c0dbc0c565 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/legacy-modes@npm:6.3.2": | ||||
|   version: 6.3.2 | ||||
|   resolution: "@codemirror/legacy-modes@npm:6.3.2" | ||||
| "@codemirror/legacy-modes@npm:6.3.3": | ||||
|   version: 6.3.3 | ||||
|   resolution: "@codemirror/legacy-modes@npm:6.3.3" | ||||
|   dependencies: | ||||
|     "@codemirror/language": ^6.0.0 | ||||
|   checksum: fa5f5477fb9e19267251e2ecd3de8c1a4c2512813555bb60111dce3951f2c3f6080a2985a573b7542534ba1d2c34115f7e39ee23fdf8f6f81db6f8ce447c1efc | ||||
|   checksum: 3cd32b0f011b0a193e0948e5901b625f38aa6d9a8b24344531d6e142eb6fbb3e6cb5969429102044f3d04fbe53c4deaebd9f659c05067a0b18d17766290c9e05 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/lint@npm:^6.0.0": | ||||
|   version: 6.2.0 | ||||
|   resolution: "@codemirror/lint@npm:6.2.0" | ||||
|   version: 6.4.1 | ||||
|   resolution: "@codemirror/lint@npm:6.4.1" | ||||
|   dependencies: | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     crelt: ^1.0.5 | ||||
|   checksum: b97e55a07bca9f7e357e495853ba189ae0ff7dfe7e7ae445d7a0d6c6926ec792c7f5c6b6c13a1f137fd9fedf44a6624e9d500f76d0d46a3c3e9d19c2cda9d28a | ||||
|   checksum: ac8120ca96b5ef57abd2705b2620c15c7449b5056bca87053480e244c6772863e1537387a863cfb784f9f2af2c8b30be78a31660d96a815672059085beb51fd5 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/search@npm:6.3.0": | ||||
|   version: 6.3.0 | ||||
|   resolution: "@codemirror/search@npm:6.3.0" | ||||
| "@codemirror/search@npm:6.5.2": | ||||
|   version: 6.5.2 | ||||
|   resolution: "@codemirror/search@npm:6.5.2" | ||||
|   dependencies: | ||||
|     "@codemirror/state": ^6.0.0 | ||||
|     "@codemirror/view": ^6.0.0 | ||||
|     crelt: ^1.0.5 | ||||
|   checksum: b757eebbb541c9d74fe36ccfdd03bc3e4e7aebb08b491e207d5898f24aaa612558c393ba49de5bf375972f5774de817fcfbad1ac551dda1a34badb41cf130d36 | ||||
|   checksum: bc535151277fda0a370ac496b9b0d5751fd91bd8e3eb29dafbfe6bf3125dc450a7e361ebc302f0ebc4193ac337bdf555ab3d5ec753dbb44452225618a5630dd3 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/state@npm:6.2.0, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.4, @codemirror/state@npm:^6.2.0": | ||||
|   version: 6.2.0 | ||||
|   resolution: "@codemirror/state@npm:6.2.0" | ||||
|   checksum: fdc99c773dc09c700dd02bf918f06132aa8d3069c262cc4eb6ca5c810ce24ae2d7e90719ae7630a8158fd263018de6d40bd78f312e6bfba754e737b64e6c6b3d | ||||
| "@codemirror/state@npm:6.2.1, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.4, @codemirror/state@npm:^6.2.0": | ||||
|   version: 6.2.1 | ||||
|   resolution: "@codemirror/state@npm:6.2.1" | ||||
|   checksum: d12a321d0471b264b9d3259042bff913a8b939e8d28d408ff452004538a71ca9d5329df3f8a1d8a9183f5b42a7ef5b200737bcab1065714f5ae8e0a5ba9d59d3 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@codemirror/view@npm:6.9.3, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.2.2, @codemirror/view@npm:^6.6.0": | ||||
|   version: 6.9.3 | ||||
|   resolution: "@codemirror/view@npm:6.9.3" | ||||
| "@codemirror/view@npm:6.18.0, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.6.0": | ||||
|   version: 6.18.0 | ||||
|   resolution: "@codemirror/view@npm:6.18.0" | ||||
|   dependencies: | ||||
|     "@codemirror/state": ^6.1.4 | ||||
|     style-mod: ^4.0.0 | ||||
|     style-mod: ^4.1.0 | ||||
|     w3c-keyname: ^2.2.4 | ||||
|   checksum: 718ecbb021ca75eb89003f73c846a07d36a708dcfec8345f0f0dbcfc0d0df5ea6f114918694b2730a6d49e5e50502bcce79ce7ff94ce55748e068e5a35073755 | ||||
|   checksum: 275bf5898e884297f16f73e4dff1b520a196a5f7724fbeda634a927e7f4036f6786e816b124505942de99800fb66c538307e8c08e55234ad57483f1a009e3d35 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -4056,6 +4058,47 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@jest/core@npm:^29.5.0, @jest/core@npm:^29.7.0": | ||||
|   version: 29.7.0 | ||||
|   resolution: "@jest/core@npm:29.7.0" | ||||
|   dependencies: | ||||
|     "@jest/console": ^29.7.0 | ||||
|     "@jest/reporters": ^29.7.0 | ||||
|     "@jest/test-result": ^29.7.0 | ||||
|     "@jest/transform": ^29.7.0 | ||||
|     "@jest/types": ^29.6.3 | ||||
|     "@types/node": "*" | ||||
|     ansi-escapes: ^4.2.1 | ||||
|     chalk: ^4.0.0 | ||||
|     ci-info: ^3.2.0 | ||||
|     exit: ^0.1.2 | ||||
|     graceful-fs: ^4.2.9 | ||||
|     jest-changed-files: ^29.7.0 | ||||
|     jest-config: ^29.7.0 | ||||
|     jest-haste-map: ^29.7.0 | ||||
|     jest-message-util: ^29.7.0 | ||||
|     jest-regex-util: ^29.6.3 | ||||
|     jest-resolve: ^29.7.0 | ||||
|     jest-resolve-dependencies: ^29.7.0 | ||||
|     jest-runner: ^29.7.0 | ||||
|     jest-runtime: ^29.7.0 | ||||
|     jest-snapshot: ^29.7.0 | ||||
|     jest-util: ^29.7.0 | ||||
|     jest-validate: ^29.7.0 | ||||
|     jest-watcher: ^29.7.0 | ||||
|     micromatch: ^4.0.4 | ||||
|     pretty-format: ^29.7.0 | ||||
|     slash: ^3.0.0 | ||||
|     strip-ansi: ^6.0.0 | ||||
|   peerDependencies: | ||||
|     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 | ||||
|   peerDependenciesMeta: | ||||
|     node-notifier: | ||||
|       optional: true | ||||
|   checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@jest/core@npm:^29.6.4": | ||||
|   version: 29.6.4 | ||||
|   resolution: "@jest/core@npm:29.6.4" | ||||
| @@ -4097,47 +4140,6 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@jest/core@npm:^29.7.0": | ||||
|   version: 29.7.0 | ||||
|   resolution: "@jest/core@npm:29.7.0" | ||||
|   dependencies: | ||||
|     "@jest/console": ^29.7.0 | ||||
|     "@jest/reporters": ^29.7.0 | ||||
|     "@jest/test-result": ^29.7.0 | ||||
|     "@jest/transform": ^29.7.0 | ||||
|     "@jest/types": ^29.6.3 | ||||
|     "@types/node": "*" | ||||
|     ansi-escapes: ^4.2.1 | ||||
|     chalk: ^4.0.0 | ||||
|     ci-info: ^3.2.0 | ||||
|     exit: ^0.1.2 | ||||
|     graceful-fs: ^4.2.9 | ||||
|     jest-changed-files: ^29.7.0 | ||||
|     jest-config: ^29.7.0 | ||||
|     jest-haste-map: ^29.7.0 | ||||
|     jest-message-util: ^29.7.0 | ||||
|     jest-regex-util: ^29.6.3 | ||||
|     jest-resolve: ^29.7.0 | ||||
|     jest-resolve-dependencies: ^29.7.0 | ||||
|     jest-runner: ^29.7.0 | ||||
|     jest-runtime: ^29.7.0 | ||||
|     jest-snapshot: ^29.7.0 | ||||
|     jest-util: ^29.7.0 | ||||
|     jest-validate: ^29.7.0 | ||||
|     jest-watcher: ^29.7.0 | ||||
|     micromatch: ^4.0.4 | ||||
|     pretty-format: ^29.7.0 | ||||
|     slash: ^3.0.0 | ||||
|     strip-ansi: ^6.0.0 | ||||
|   peerDependencies: | ||||
|     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 | ||||
|   peerDependenciesMeta: | ||||
|     node-notifier: | ||||
|       optional: true | ||||
|   checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@jest/create-cache-key-function@npm:^27.0.1": | ||||
|   version: 27.4.2 | ||||
|   resolution: "@jest/create-cache-key-function@npm:27.4.2" | ||||
| @@ -4637,6 +4639,7 @@ __metadata: | ||||
|     "@electron/remote": 2.0.11 | ||||
|     "@fortawesome/fontawesome-free": 5.15.4 | ||||
|     "@joeattardi/emoji-button": 4.6.4 | ||||
|     "@joplin/editor": ~2.13 | ||||
|     "@joplin/lib": ~2.13 | ||||
|     "@joplin/renderer": ~2.13 | ||||
|     "@joplin/tools": ~2.13 | ||||
| @@ -4712,20 +4715,8 @@ __metadata: | ||||
|     "@babel/core": 7.20.2 | ||||
|     "@babel/preset-env": 7.20.2 | ||||
|     "@babel/runtime": 7.20.0 | ||||
|     "@bam.tech/react-native-image-resizer": 3.0.7 | ||||
|     "@codemirror/commands": 6.2.2 | ||||
|     "@codemirror/lang-cpp": 6.0.2 | ||||
|     "@codemirror/lang-html": 6.4.3 | ||||
|     "@codemirror/lang-java": 6.0.1 | ||||
|     "@codemirror/lang-javascript": 6.1.5 | ||||
|     "@codemirror/lang-markdown": 6.1.0 | ||||
|     "@codemirror/lang-php": 6.0.1 | ||||
|     "@codemirror/lang-rust": 6.0.1 | ||||
|     "@codemirror/language": 6.6.0 | ||||
|     "@codemirror/legacy-modes": 6.3.2 | ||||
|     "@codemirror/search": 6.3.0 | ||||
|     "@codemirror/state": 6.2.0 | ||||
|     "@codemirror/view": 6.9.3 | ||||
|     "@bam.tech/react-native-image-resizer": 3.0.5 | ||||
|     "@joplin/editor": ~2.13 | ||||
|     "@joplin/lib": ~2.13 | ||||
|     "@joplin/react-native-alarm-notification": ~2.13 | ||||
|     "@joplin/react-native-saf-x": ~2.13 | ||||
| @@ -4822,6 +4813,40 @@ __metadata: | ||||
|   languageName: unknown | ||||
|   linkType: soft | ||||
| 
 | ||||
| "@joplin/editor@workspace:packages/editor, @joplin/editor@~2.13": | ||||
|   version: 0.0.0-use.local | ||||
|   resolution: "@joplin/editor@workspace:packages/editor" | ||||
|   dependencies: | ||||
|     "@codemirror/autocomplete": 6.9.0 | ||||
|     "@codemirror/commands": 6.2.5 | ||||
|     "@codemirror/lang-cpp": 6.0.2 | ||||
|     "@codemirror/lang-html": 6.4.6 | ||||
|     "@codemirror/lang-java": 6.0.1 | ||||
|     "@codemirror/lang-javascript": 6.2.1 | ||||
|     "@codemirror/lang-markdown": 6.2.1 | ||||
|     "@codemirror/lang-php": 6.0.1 | ||||
|     "@codemirror/lang-rust": 6.0.1 | ||||
|     "@codemirror/language": 6.9.0 | ||||
|     "@codemirror/legacy-modes": 6.3.3 | ||||
|     "@codemirror/search": 6.5.2 | ||||
|     "@codemirror/state": 6.2.1 | ||||
|     "@codemirror/view": 6.18.0 | ||||
|     "@joplin/lib": ~2.13 | ||||
|     "@joplin/tools": ~2.13 | ||||
|     "@lezer/markdown": 1.1.0 | ||||
|     "@replit/codemirror-vim": 6.0.14 | ||||
|     "@testing-library/react-hooks": 8.0.1 | ||||
|     "@types/jest": 29.5.3 | ||||
|     "@types/react": 18.0.24 | ||||
|     "@types/react-redux": 7.1.25 | ||||
|     "@types/styled-components": 5.1.26 | ||||
|     jest: 29.5.0 | ||||
|     jest-environment-jsdom: 29.5.0 | ||||
|     ts-jest: 29.1.1 | ||||
|     typescript: 5.1.3 | ||||
|   languageName: unknown | ||||
|   linkType: soft | ||||
| 
 | ||||
| "@joplin/fork-htmlparser2@^4.1.46, @joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2": | ||||
|   version: 0.0.0-use.local | ||||
|   resolution: "@joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2" | ||||
| @@ -6178,34 +6203,34 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/common@npm:^1.0.0": | ||||
|   version: 1.0.2 | ||||
|   resolution: "@lezer/common@npm:1.0.2" | ||||
|   checksum: bbcc58e07be02652bf0700d2856042ec089d5be0b95893d628b3e18192ade864fac83b61b19653e10b9f1472261a178b12318d934e9004edd5483a577c0db56b | ||||
| "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2": | ||||
|   version: 1.0.4 | ||||
|   resolution: "@lezer/common@npm:1.0.4" | ||||
|   checksum: 0bea82da76e0b89afad4e5159d3add460022916352c47906ec67b26d6fe5ec9cb8e23df0e2bf0adef765ae78bed1706fc573a11506d01a80112a5b6dd317730c | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/cpp@npm:^1.0.0": | ||||
|   version: 1.1.0 | ||||
|   resolution: "@lezer/cpp@npm:1.1.0" | ||||
|   version: 1.1.1 | ||||
|   resolution: "@lezer/cpp@npm:1.1.1" | ||||
|   dependencies: | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|   checksum: 9b25c881fc9b64fd2b019a077a85b0ba7cfda0bbdd92dbb0ff43300c9ba1ec4360128fe912bfe0f06a1c1bb5a564c5ace375c8aad254d07a717768a8f268695d | ||||
|   checksum: c9e1db19776eafbfe0c3b8448d46c94d9a1d30f7fef630292e63bab82e6d5d6903a043ee8cf341bcbf84c00ee0d79b8c255bab8fd8e0a91355ae912b53c78935 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/css@npm:^1.0.0, @lezer/css@npm:^1.1.0": | ||||
|   version: 1.1.1 | ||||
|   resolution: "@lezer/css@npm:1.1.1" | ||||
|   version: 1.1.3 | ||||
|   resolution: "@lezer/css@npm:1.1.3" | ||||
|   dependencies: | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|   checksum: a7e4893aacaa7f26d5679c77a640f401b37d14155cb54863aa91b59dfd220b280360a341c0fedafc65d31101de13a5ae33cf3876c352f2da528344dafdc9b3d7 | ||||
|   checksum: c8069ef0a6751441d2dc9180f7ebfd7aeb35df0ca2f1a748a2f26203a9ef6cc30f17f3074e2b49520453eb39329dadfdbbb901c6d9d067dc955ceb58c1f8cc6a | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/highlight@npm:1.1.4, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3": | ||||
| "@lezer/highlight@npm:1.1.4": | ||||
|   version: 1.1.4 | ||||
|   resolution: "@lezer/highlight@npm:1.1.4" | ||||
|   dependencies: | ||||
| @@ -6214,53 +6239,62 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3": | ||||
|   version: 1.1.6 | ||||
|   resolution: "@lezer/highlight@npm:1.1.6" | ||||
|   dependencies: | ||||
|     "@lezer/common": ^1.0.0 | ||||
|   checksum: 411a702394c4c996b7d7f145a38f3a85a8cc698b3918acc7121c629255bb76d4ab383753f69009e011dc415210c6acbbb5b27bde613259ab67e600b29397b03b | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/html@npm:^1.3.0": | ||||
|   version: 1.3.4 | ||||
|   resolution: "@lezer/html@npm:1.3.4" | ||||
|   version: 1.3.6 | ||||
|   resolution: "@lezer/html@npm:1.3.6" | ||||
|   dependencies: | ||||
|     "@lezer/common": ^1.0.0 | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|   checksum: 81dd134ac094edf7c40bae4c3b7126d336ce4c3c87756344bf604eff64d89b06fcb55f91618a4622eb0dae6d6015722f5bab58e2252d86e81fca8c3ced1a0c4d | ||||
|   checksum: 1d3af781660968505e5083a34f31ea3549fd5f3949227fa93cc318bca61bce76ffe977bd875624ba938a2039834ec1a33df5d365e94c48131c85dd26f980d92c | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/java@npm:^1.0.0": | ||||
|   version: 1.0.3 | ||||
|   resolution: "@lezer/java@npm:1.0.3" | ||||
|   version: 1.0.4 | ||||
|   resolution: "@lezer/java@npm:1.0.4" | ||||
|   dependencies: | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|   checksum: 2fffea6627d130413ffad4e61040267974cca3167d98881b9e5b5e2455530de74a82c234d93603e92a4972fad314671453c49c0a76b0f4547c4617d671fd7b99 | ||||
|   checksum: 97f5a2c2d733afba5dc57a0da9a97515b19b5e63bb5937717dac4e8c9baed74d15c0cb5c1580858b678931f11d517c56d89f903968fa48931f9c62e2ea67a107 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/javascript@npm:^1.0.0": | ||||
|   version: 1.4.2 | ||||
|   resolution: "@lezer/javascript@npm:1.4.2" | ||||
|   version: 1.4.7 | ||||
|   resolution: "@lezer/javascript@npm:1.4.7" | ||||
|   dependencies: | ||||
|     "@lezer/highlight": ^1.1.3 | ||||
|     "@lezer/lr": ^1.3.0 | ||||
|   checksum: 542261c297709babfe450de1233c13fe2f5b111678d280cb0f8304f12bcdae294cb43c0ac64bbd647e5039de3286f6f0715d120fb132bd5af778363d1f612a1f | ||||
|   checksum: 37c05793e0e45280fa5d7b845a3132a84596105d48b7d2c195abea0a198477ea6719b07d1c8967679e80fc466388151956901fd6962479c130ffda64a6d09591 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0": | ||||
|   version: 1.3.3 | ||||
|   resolution: "@lezer/lr@npm:1.3.3" | ||||
|   version: 1.3.10 | ||||
|   resolution: "@lezer/lr@npm:1.3.10" | ||||
|   dependencies: | ||||
|     "@lezer/common": ^1.0.0 | ||||
|   checksum: 1804074c794005a31c54d80ab72127f19ae5be29bb627c52bc001a57b1af97a9e62732ff13e3aeb7bc53b330202b6bd3747272c64d87f257dbba533e75a183a3 | ||||
|   checksum: 9d3c22bf692561cf7fe2f3d14e821913f87116ff9d73b8b550e7998b6135baae9f504563846a4257e1bb4eae97ae1b60c06c6066450ddeef5e03e8783526b2ae | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/markdown@npm:^1.0.0": | ||||
|   version: 1.0.2 | ||||
|   resolution: "@lezer/markdown@npm:1.0.2" | ||||
| "@lezer/markdown@npm:1.1.0, @lezer/markdown@npm:^1.0.0": | ||||
|   version: 1.1.0 | ||||
|   resolution: "@lezer/markdown@npm:1.1.0" | ||||
|   dependencies: | ||||
|     "@lezer/common": ^1.0.0 | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|   checksum: c4bbfcd8a5a9d924a7cf2b5e5e99c78e7705473cc59804070278b5cfcf478af9dd567025d0926cbf03e3ea6abb8f173425220d3107c05a2d7e0ca3fe3d5c92ef | ||||
|   checksum: b3699c0724dd41e3e6e3078a0e1bcd272ccaebf17b20e5160de3ecf26200cdaa59aa19c9542aac5ab8c7e3aecce1003544b016bb5c32e458bbd5982add8ca0bf | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -6275,12 +6309,12 @@ __metadata: | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@lezer/rust@npm:^1.0.0": | ||||
|   version: 1.0.0 | ||||
|   resolution: "@lezer/rust@npm:1.0.0" | ||||
|   version: 1.0.1 | ||||
|   resolution: "@lezer/rust@npm:1.0.1" | ||||
|   dependencies: | ||||
|     "@lezer/highlight": ^1.0.0 | ||||
|     "@lezer/lr": ^1.0.0 | ||||
|   checksum: 0c42f415674f60ca2ef4274b446577621cdeec8f31168b1c3b90888a4377c513f02a89ee346421c264ec3a77fe2fa3e134996be6463ed506dbbc79b4b4505375 | ||||
|   checksum: 1e02fdf09206979e7d4f87b020589f410c4c5e452a7b7b0296f6772ce3571c1bd7ed37495fbeeecf3d4423000f2efdabd462ba8a949c2b351fd35550327a7613 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| @@ -7189,6 +7223,19 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@replit/codemirror-vim@npm:6.0.14": | ||||
|   version: 6.0.14 | ||||
|   resolution: "@replit/codemirror-vim@npm:6.0.14" | ||||
|   peerDependencies: | ||||
|     "@codemirror/commands": ^6.0.0 | ||||
|     "@codemirror/language": ^6.1.0 | ||||
|     "@codemirror/search": ^6.2.0 | ||||
|     "@codemirror/state": ^6.0.1 | ||||
|     "@codemirror/view": ^6.0.3 | ||||
|   checksum: 43d14512172df23a47818a8f19ead1733cc1dc7c77cf27d6fd3bc75d645b0400affd96c15e32d2985404b603a09b9296dab9173c501096b42e5e8e8092dbfe0f | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@rmp135/sql-ts@npm:1.18.0": | ||||
|   version: 1.18.0 | ||||
|   resolution: "@rmp135/sql-ts@npm:1.18.0" | ||||
| @@ -7887,6 +7934,16 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/jest@npm:29.5.3": | ||||
|   version: 29.5.3 | ||||
|   resolution: "@types/jest@npm:29.5.3" | ||||
|   dependencies: | ||||
|     expect: ^29.0.0 | ||||
|     pretty-format: ^29.0.0 | ||||
|   checksum: e36bb92e0b9e5ea7d6f8832baa42f087fc1697f6cd30ec309a07ea4c268e06ec460f1f0cfd2581daf5eff5763475190ec1ad8ac6520c49ccfe4f5c0a48bfa676 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/jest@npm:29.5.4": | ||||
|   version: 29.5.4 | ||||
|   resolution: "@types/jest@npm:29.5.4" | ||||
| @@ -8243,6 +8300,18 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/react-redux@npm:7.1.25": | ||||
|   version: 7.1.25 | ||||
|   resolution: "@types/react-redux@npm:7.1.25" | ||||
|   dependencies: | ||||
|     "@types/hoist-non-react-statics": ^3.3.0 | ||||
|     "@types/react": "*" | ||||
|     hoist-non-react-statics: ^3.3.0 | ||||
|     redux: ^4.0.0 | ||||
|   checksum: a61ec25cbf8bb3720850402d3c49493fcff4afb73ad447d161460b5d4c600c984ad48708e8564d2fd32052eaa3c3b3f655c5b300ce813429637cce9e5958329f | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/react-redux@npm:7.1.26": | ||||
|   version: 7.1.26 | ||||
|   resolution: "@types/react-redux@npm:7.1.26" | ||||
| @@ -8275,6 +8344,17 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/react@npm:18.0.24": | ||||
|   version: 18.0.24 | ||||
|   resolution: "@types/react@npm:18.0.24" | ||||
|   dependencies: | ||||
|     "@types/prop-types": "*" | ||||
|     "@types/scheduler": "*" | ||||
|     csstype: ^3.0.2 | ||||
|   checksum: 7d06125bac61e1c6661e5dfbeeeb56d5b6d1d4c743292faebaa6b0f30f8414c7af3cadf674923fd86e4ca14e82566ff9156cd40c56786be024600c31b97d6c03 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "@types/react@npm:18.2.21": | ||||
|   version: 18.2.21 | ||||
|   resolution: "@types/react@npm:18.2.21" | ||||
| @@ -20939,7 +21019,7 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "jest-cli@npm:^29.6.4": | ||||
| "jest-cli@npm:^29.5.0, jest-cli@npm:^29.6.4": | ||||
|   version: 29.7.0 | ||||
|   resolution: "jest-cli@npm:29.7.0" | ||||
|   dependencies: | ||||
| @@ -21145,6 +21225,27 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "jest-environment-jsdom@npm:29.5.0": | ||||
|   version: 29.5.0 | ||||
|   resolution: "jest-environment-jsdom@npm:29.5.0" | ||||
|   dependencies: | ||||
|     "@jest/environment": ^29.5.0 | ||||
|     "@jest/fake-timers": ^29.5.0 | ||||
|     "@jest/types": ^29.5.0 | ||||
|     "@types/jsdom": ^20.0.0 | ||||
|     "@types/node": "*" | ||||
|     jest-mock: ^29.5.0 | ||||
|     jest-util: ^29.5.0 | ||||
|     jsdom: ^20.0.0 | ||||
|   peerDependencies: | ||||
|     canvas: ^2.5.0 | ||||
|   peerDependenciesMeta: | ||||
|     canvas: | ||||
|       optional: true | ||||
|   checksum: 3df7fc85275711f20b483ac8cd8c04500704ed0f69791eb55c574b38f5a39470f03d775cf20c1025bc1884916ac0573aa2fa4db1bb74225bc7fdd95ba97ad0da | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "jest-environment-jsdom@npm:29.6.4": | ||||
|   version: 29.6.4 | ||||
|   resolution: "jest-environment-jsdom@npm:29.6.4" | ||||
| @@ -21919,6 +22020,25 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "jest@npm:29.5.0": | ||||
|   version: 29.5.0 | ||||
|   resolution: "jest@npm:29.5.0" | ||||
|   dependencies: | ||||
|     "@jest/core": ^29.5.0 | ||||
|     "@jest/types": ^29.5.0 | ||||
|     import-local: ^3.0.2 | ||||
|     jest-cli: ^29.5.0 | ||||
|   peerDependencies: | ||||
|     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 | ||||
|   peerDependenciesMeta: | ||||
|     node-notifier: | ||||
|       optional: true | ||||
|   bin: | ||||
|     jest: bin/jest.js | ||||
|   checksum: a8ff2eb0f421623412236e23cbe67c638127fffde466cba9606bc0c0553b4c1e5cb116d7e0ef990b5d1712851652c8ee461373b578df50857fe635b94ff455d5 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "jest@npm:29.6.4": | ||||
|   version: 29.6.4 | ||||
|   resolution: "jest@npm:29.6.4" | ||||
| @@ -32766,6 +32886,13 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "style-mod@npm:^4.1.0": | ||||
|   version: 4.1.0 | ||||
|   resolution: "style-mod@npm:4.1.0" | ||||
|   checksum: 8402b14ca11113a3640d46b3cf7ba49f05452df7846bc5185a3535d9b6a64a3019e7fb636b59ccbb7816aeb0725b24723e77a85b05612a9360e419958e13b4e6 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "styled-components@npm:5.3.11": | ||||
|   version: 5.3.11 | ||||
|   resolution: "styled-components@npm:5.3.11" | ||||
| @@ -34349,6 +34476,16 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "typescript@npm:5.1.3": | ||||
|   version: 5.1.3 | ||||
|   resolution: "typescript@npm:5.1.3" | ||||
|   bin: | ||||
|     tsc: bin/tsc | ||||
|     tsserver: bin/tsserver | ||||
|   checksum: d9d51862d98efa46534f2800a1071a613751b1585dc78884807d0c179bcd93d6e9d4012a508e276742f5f33c480adefc52ffcafaf9e0e00ab641a14cde9a31c7 | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "typescript@npm:5.1.6": | ||||
|   version: 5.1.6 | ||||
|   resolution: "typescript@npm:5.1.6" | ||||
| @@ -34399,6 +34536,16 @@ __metadata: | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "typescript@patch:typescript@5.1.3#~builtin<compat/typescript>": | ||||
|   version: 5.1.3 | ||||
|   resolution: "typescript@patch:typescript@npm%3A5.1.3#~builtin<compat/typescript>::version=5.1.3&hash=5da071" | ||||
|   bin: | ||||
|     tsc: bin/tsc | ||||
|     tsserver: bin/tsserver | ||||
|   checksum: 6f0a9dca6bf4ce9dcaf4e282aade55ef4c56ecb5fb98d0a4a5c0113398815aea66d871b5611e83353e5953a19ed9ef103cf5a76ac0f276d550d1e7cd5344f61e | ||||
|   languageName: node | ||||
|   linkType: hard | ||||
| 
 | ||||
| "typescript@patch:typescript@5.1.6#~builtin<compat/typescript>": | ||||
|   version: 5.1.6 | ||||
|   resolution: "typescript@patch:typescript@npm%3A5.1.6#~builtin<compat/typescript>::version=5.1.6&hash=5da071" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user