diff --git a/.eslintignore b/.eslintignore index e867c55f27..e25025f04f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -943,6 +943,8 @@ packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js packages/editor/CodeMirror/markdown/computeSelectionFormatting.js packages/editor/CodeMirror/markdown/decoratorExtension.test.js packages/editor/CodeMirror/markdown/decoratorExtension.js +packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.js +packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.js packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js packages/editor/CodeMirror/markdown/markdownCommands.test.js packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js diff --git a/.gitignore b/.gitignore index 171a383b9a..43dd1d28e2 100644 --- a/.gitignore +++ b/.gitignore @@ -917,6 +917,8 @@ packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js packages/editor/CodeMirror/markdown/computeSelectionFormatting.js packages/editor/CodeMirror/markdown/decoratorExtension.test.js packages/editor/CodeMirror/markdown/decoratorExtension.js +packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.js +packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.js packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js packages/editor/CodeMirror/markdown/markdownCommands.test.js packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js diff --git a/packages/editor/CodeMirror/configFromSettings.ts b/packages/editor/CodeMirror/configFromSettings.ts index ddff030399..4f3e401069 100644 --- a/packages/editor/CodeMirror/configFromSettings.ts +++ b/packages/editor/CodeMirror/configFromSettings.ts @@ -3,7 +3,7 @@ import { closeBrackets, closeBracketsKeymap } 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 { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; import MarkdownMathExtension from './markdown/MarkdownMathExtension'; import MarkdownHighlightExtension from './markdown/MarkdownHighlightExtension'; @@ -13,6 +13,7 @@ import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands'; import { vim } from '@replit/codemirror-vim'; import { indentUnit } from '@codemirror/language'; import { Prec } from '@codemirror/state'; +import insertNewlineContinueMarkup from './markdown/insertNewlineContinueMarkup'; const configFromSettings = (settings: EditorSettings) => { const languageExtension = (() => { @@ -34,6 +35,7 @@ const configFromSettings = (settings: EditorSettings) => { ...(settings.autocompleteMarkup ? { // Most Markup completion is enabled by default + addKeymap: false, // However, we have our own keymap } : { addKeymap: false, completeHTMLTags: false, @@ -41,6 +43,10 @@ const configFromSettings = (settings: EditorSettings) => { }), }), markdownLanguage.data.of({ closeBrackets: { brackets: openingBrackets } }), + keymap.of(settings.autocompleteMarkup ? [ + { key: 'Enter', run: insertNewlineContinueMarkup }, + { key: 'Backspace', run: deleteMarkupBackward }, + ] : []), ]; } else if (language === EditorLanguageType.Html) { return html({ autoCloseTags: settings.autocompleteMarkup }); diff --git a/packages/editor/CodeMirror/editorCommands/insertLineAfter.ts b/packages/editor/CodeMirror/editorCommands/insertLineAfter.ts index c8c84f6c0b..a1c3fe5fa3 100644 --- a/packages/editor/CodeMirror/editorCommands/insertLineAfter.ts +++ b/packages/editor/CodeMirror/editorCommands/insertLineAfter.ts @@ -1,7 +1,7 @@ import { insertNewlineAndIndent } from '@codemirror/commands'; -import { insertNewlineContinueMarkup } from '@codemirror/lang-markdown'; import { EditorSelection, SelectionRange } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; +import insertNewlineContinueMarkup from '../markdown/insertNewlineContinueMarkup'; const insertLineAfter = (view: EditorView) => { const state = view.state; diff --git a/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.ts b/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.ts new file mode 100644 index 0000000000..6340b5665f --- /dev/null +++ b/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.ts @@ -0,0 +1,131 @@ +import { EditorSelection } from '@codemirror/state'; +import createTestEditor from '../testUtil/createTestEditor'; +import pressReleaseKey from '../testUtil/pressReleaseKey'; +import { keymap } from '@codemirror/view'; +import insertNewlineContinueMarkup from './insertNewlineContinueMarkup'; + +describe('insertNewlineContinueMarkup', () => { + jest.retryTimes(2); + + it.each([ + { // Should continue bulleted lists + before: [ + '- Testing', + '- Test', + ], + afterEnterPress: [ + '- Testing', + '- Test', + '- ', + ], + }, + { + // Should continue bulleted lists separated by blank lines + before: [ + '- Testing', + '', + '- Test', + ], + afterEnterPress: [ + '- Testing', + '', + // Note: This is our reason for forking the indentation logic. See + // https://github.com/laurent22/joplin/issues/10226 + '- Test', + '- ', + ], + }, + { + // Should allow creating non-tight lists + before: [ + '- Testing', + '- ', + ], + afterEnterPress: [ + '- Testing', + '', + '- ', + ], + }, + { // Should continue nested numbered lists + before: [ + '- Testing', + '\t1. Test', + '\t2. Test 2', + ], + afterEnterPress: [ + '- Testing', + '\t1. Test', + '\t2. Test 2', + '\t3. ', + ], + }, + { // Should continue nested bulleted lists + before: [ + '- Testing', + '\t- Test', + '\t- Test 2', + '\t- ', + ], + afterEnterPress: [ + '- Testing', + '\t- Test', + '\t- Test 2', + ' ', + '\t- ', + ], + afterEnterPressTwice: [ + '- Testing', + '\t- Test', + '\t- Test 2', + ' ', + '- ', + ], + }, + { // Should end lists + before: [ + '- Testing', + '- Test', + '- ', + ], + afterEnterPress: [ + '- Testing', + '- Test', + '', + '- ', + ], + afterEnterPressTwice: [ + '- Testing', + '- Test', + '', + '', + ], + }, + + ])('pressing enter should correctly end or continue lists (case %#)', async ({ before, afterEnterPress, afterEnterPressTwice }) => { + const initialDocText = before.join('\n'); + const editor = await createTestEditor( + initialDocText, + EditorSelection.cursor(initialDocText.length), + ['BulletList'], + [ + keymap.of([ + { key: 'Enter', run: insertNewlineContinueMarkup }, + ]), + ], + false, + ); + + const pressEnter = () => { + pressReleaseKey(editor, { key: 'Enter', code: 'Enter', typesText: '\n' }); + }; + + pressEnter(); + expect(editor.state.doc.toString()).toBe(afterEnterPress.join('\n')); + + if (afterEnterPressTwice) { + pressEnter(); + expect(editor.state.doc.toString()).toBe(afterEnterPressTwice.join('\n')); + } + }); +}); diff --git a/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.ts b/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.ts new file mode 100644 index 0000000000..317e134fd3 --- /dev/null +++ b/packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.ts @@ -0,0 +1,193 @@ +// This is a fork of CodeMirror's insertNewlineContinueMarkup, which is based on the +// version of the file before this commit: https://github.com/codemirror/lang-markdown/commit/fa289d542f65451957c562780d5dd846bee060d4 +// +// Newer versions of the code handle non-tight lists in a way that many users find +// unexpected. +// +// The original source has the following license: +// ! +// ! Copyright (C) 2018-2021 by Marijn Haverbeke and others +// ! +// ! Permission is hereby granted, free of charge, to any person obtaining a copy +// ! of this software and associated documentation files (the "Software"), to deal +// ! in the Software without restriction, including without limitation the rights +// ! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// ! copies of the Software, and to permit persons to whom the Software is +// ! furnished to do so, subject to the following conditions: +// ! +// ! The above copyright notice and this permission notice shall be included in +// ! all copies or substantial portions of the Software. + +import { markdownLanguage } from '@codemirror/lang-markdown'; +import { indentUnit, syntaxTree } from '@codemirror/language'; +import { ChangeSpec, countColumn, EditorSelection, EditorState, StateCommand, Text } from '@codemirror/state'; +import { SyntaxNode } from '@lezer/common'; + + +class Context { + public constructor( + public readonly node: SyntaxNode, + public readonly from: number, + public readonly to: number, + public readonly spaceBefore: string, + public readonly spaceAfter: string, + public readonly type: string, + public readonly item: SyntaxNode | null, + ) { } + + public blank(maxWidth: number | null, trailing = true) { + let result = this.spaceBefore + (this.node.name === 'Blockquote' ? '>' : ''); + if (maxWidth !== null) { + while (result.length < maxWidth) result += ' '; + return result; + } else { + for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--) result += ' '; + return result + (trailing ? this.spaceAfter : ''); + } + } + + public marker(doc: Text, add: number) { + const number = this.node.name === 'OrderedList' ? String((+itemNumber(this.item!, doc)[2] + add)) : ''; + return this.spaceBefore + number + this.type + this.spaceAfter; + } +} + +function getContext(node: SyntaxNode, doc: Text) { + const nodes = []; + for (let cur: SyntaxNode | null = node; cur && cur.name !== 'Document'; cur = cur.parent) { + if (cur.name === 'ListItem' || cur.name === 'Blockquote' || cur.name === 'FencedCode') { nodes.push(cur); } + } + const context = []; + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + let match; + const line = doc.lineAt(node.from), startPos = node.from - line.from; + if (node.name === 'FencedCode') { + context.push(new Context(node, startPos, startPos, '', '', '', null)); + } else if (node.name === 'Blockquote' && (match = /^ *>( ?)/.exec(line.text.slice(startPos)))) { + context.push(new Context(node, startPos, startPos + match[0].length, '', match[1], '>', null)); + } else if (node.name === 'ListItem' && node.parent!.name === 'OrderedList' && + (match = /^( *)\d+([.)])( *)/.exec(line.text.slice(startPos)))) { + let after = match[3], len = match[0].length; + if (after.length >= 4) { after = after.slice(0, after.length - 4); len -= 4; } + context.push(new Context(node.parent!, startPos, startPos + len, match[1], after, match[2], node)); + } else if (node.name === 'ListItem' && node.parent!.name === 'BulletList' && + (match = /^( *)([-+*])( {1,4}\[[ xX]\])?( +)/.exec(line.text.slice(startPos)))) { + let after = match[4], len = match[0].length; + if (after.length > 4) { after = after.slice(0, after.length - 4); len -= 4; } + let type = match[2]; + if (match[3]) type += match[3].replace(/[xX]/, ' '); + context.push(new Context(node.parent!, startPos, startPos + len, match[1], after, type, node)); + } + } + return context; +} + +function itemNumber(item: SyntaxNode, doc: Text) { + return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10))!; +} + +function normalizeIndent(content: string, state: EditorState) { + const blank = /^[ \t]*/.exec(content)![0].length; + if (!blank || state.facet(indentUnit) !== '\t') return content; + const col = countColumn(content, 4, blank); + let space = ''; + for (let i = col; i > 0;) { + if (i >= 4) { space += '\t'; i -= 4; } else { space += ' '; i--; } + } + return space + content.slice(blank); +} + +function renumberList(after: SyntaxNode, doc: Text, changes: ChangeSpec[], offset = 0) { + for (let prev = -1, node = after; ;) { + if (node.name === 'ListItem') { + const m = itemNumber(node, doc); + const number = +m[2]; + if (prev >= 0) { + if (number !== prev + 1) return; + changes.push({ from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset) }); + } + prev = number; + } + const next = node.nextSibling; + if (!next) break; + node = next; + } +} + +const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) => { + const tree = syntaxTree(state), { doc } = state; + let dont = null; + const changes = state.changeByRange(range => { + if (!range.empty || !markdownLanguage.isActiveAt(state, range.from)) return dont = { range }; + const pos = range.from, line = doc.lineAt(pos); + const context = getContext(tree.resolveInner(pos, -1), doc); + while (context.length && context[context.length - 1].from > pos - line.from) context.pop(); + if (!context.length) return dont = { range }; + const inner = context[context.length - 1]; + if (inner.to - inner.spaceAfter.length > pos - line.from) return dont = { range }; + + const emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to)); + // Empty line in list + if (inner.item && emptyLine) { + // First list item or blank line before: delete a level of markup + if (inner.node.firstChild!.to >= pos || + line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text)) { + const next = context.length > 1 ? context[context.length - 2] : null; + let delTo, insert = ''; + if (next && next.item) { // Re-add marker for the list at the next level + delTo = line.from + next.from; + insert = next.marker(doc, 1); + } else { + delTo = line.from + (next ? next.to : 0); + } + const changes: ChangeSpec[] = [{ from: delTo, to: pos, insert }]; + if (inner.node.name === 'OrderedList') renumberList(inner.item!, doc, changes, -2); + if (next && next.node.name === 'OrderedList') renumberList(next.item!, doc, changes); + return { range: EditorSelection.cursor(delTo + insert.length), changes }; + } else { // Move this line down + let insert = ''; + for (let i = 0, e = context.length - 2; i <= e; i++) { + insert += context[i].blank(i < e ? countColumn(line.text, 4, context[i + 1].from) - insert.length : null, i < e); + } + insert = normalizeIndent(insert, state); + return { + range: EditorSelection.cursor(pos + insert.length + 1), + changes: { from: line.from, insert: insert + state.lineBreak }, + }; + } + } + + if (inner.node.name === 'Blockquote' && emptyLine && line.from) { + const prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text); + // Two aligned empty quoted lines in a row + if (quoted && quoted.index === inner.from) { + const changes = state.changes([{ from: prevLine.from + quoted.index, to: prevLine.to }, + { from: line.from + inner.from, to: line.to }]); + return { range: range.map(changes), changes }; + } + } + + const changes: ChangeSpec[] = []; + if (inner.node.name === 'OrderedList') renumberList(inner.item!, doc, changes); + const continued = inner.item && inner.item.from < line.from; + let insert = ''; + // If not de-indented + if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)![0].length >= inner.to) { + for (let i = 0, e = context.length - 1; i <= e; i++) { + insert += i === e && !continued ? context[i].marker(doc, 1) + : context[i].blank(i < e ? countColumn(line.text, 4, context[i + 1].from) - insert.length : null); + } + } + let from = pos; + while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1))) from--; + insert = normalizeIndent(insert, state); + changes.push({ from, to: pos, insert: state.lineBreak + insert }); + return { range: EditorSelection.cursor(from + insert.length + 1), changes }; + }); + if (dont) return false; + dispatch(state.update(changes, { scrollIntoView: true, userEvent: 'input' })); + return true; +}; + +export default insertNewlineContinueMarkup; diff --git a/packages/editor/CodeMirror/testUtil/createTestEditor.ts b/packages/editor/CodeMirror/testUtil/createTestEditor.ts index 37cd32c75e..302dd6a078 100644 --- a/packages/editor/CodeMirror/testUtil/createTestEditor.ts +++ b/packages/editor/CodeMirror/testUtil/createTestEditor.ts @@ -15,6 +15,7 @@ const createTestEditor = async ( initialSelection: SelectionRange, expectedSyntaxTreeTags: string[], extraExtensions: Extension[] = [], + addMarkdownKeymap = true, ): Promise => { await loadLanguages(); @@ -24,6 +25,7 @@ const createTestEditor = async ( extensions: [ markdown({ extensions: [MarkdownMathExtension, MarkdownHighlightExtension, GithubFlavoredMarkdownExt], + addKeymap: addMarkdownKeymap, }), indentUnit.of('\t'), EditorState.tabSize.of(4), diff --git a/packages/editor/CodeMirror/testUtil/pressReleaseKey.ts b/packages/editor/CodeMirror/testUtil/pressReleaseKey.ts index 718552734f..49e6bdddee 100644 --- a/packages/editor/CodeMirror/testUtil/pressReleaseKey.ts +++ b/packages/editor/CodeMirror/testUtil/pressReleaseKey.ts @@ -1,20 +1,31 @@ import { EditorView } from '@codemirror/view'; +import typeText from './typeText'; interface KeyInfo { key: string; code: string; + // Text to type if the event was not processed + typesText?: string; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; } const pressReleaseKey = (editor: EditorView, key: KeyInfo) => { - editor.contentDOM.dispatchEvent( - new KeyboardEvent('keydown', key), - ); - editor.contentDOM.dispatchEvent( - new KeyboardEvent('keyup', key), - ); + const keyDownEvent = new KeyboardEvent('keydown', key); + + let keyDownPrevented = false; + keyDownEvent.preventDefault = () => { + keyDownPrevented = true; + }; + + editor.contentDOM.dispatchEvent(keyDownEvent); + + if (key.typesText && !keyDownPrevented) { + typeText(editor, key.typesText); + } + + editor.contentDOM.dispatchEvent(new KeyboardEvent('keyup', key)); }; export default pressReleaseKey; diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index a37891ce7e..e01237a34a 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -172,6 +172,8 @@ ggml Minidump collapseall newfolder +Marijn +Haverbeke unfocusable unlocker Tiktok