mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-08 13:06:15 +02:00
Desktop: Add new beta Markdown editor based on CodeMirror 6 (#8793)
This commit is contained in:
parent
c3971ff226
commit
84c6de9b56
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user