1
0
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:
Henry Heino 2023-09-21 01:12:40 -07:00 committed by GitHub
parent c3971ff226
commit 84c6de9b56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 4201 additions and 1306 deletions

View File

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

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

View File

@ -5,6 +5,7 @@
"exceptions": [
"@joplin/lib",
"@joplin/renderer",
"@joplin/editor",
"@joplin/pdf-viewer",
"@joplin/fork-htmlparser2",
"@joplin/fork-sax",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View 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,
};

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

View 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"
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"**/node_modules",
"**/dist"
]
}

163
packages/editor/types.ts Normal file
View 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,
}

View File

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

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