1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Desktop, Mobile: Fixes #10226: Make list continuation logic more predictable (#11919)

This commit is contained in:
Henry Heino
2025-04-24 01:06:15 -07:00
committed by GitHub
parent d6409b7826
commit 66f6310c17
9 changed files with 357 additions and 8 deletions

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -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 <marijn@haverbeke.berlin> 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;

View File

@ -15,6 +15,7 @@ const createTestEditor = async (
initialSelection: SelectionRange,
expectedSyntaxTreeTags: string[],
extraExtensions: Extension[] = [],
addMarkdownKeymap = true,
): Promise<EditorView> => {
await loadLanguages();
@ -24,6 +25,7 @@ const createTestEditor = async (
extensions: [
markdown({
extensions: [MarkdownMathExtension, MarkdownHighlightExtension, GithubFlavoredMarkdownExt],
addKeymap: addMarkdownKeymap,
}),
indentUnit.of('\t'),
EditorState.tabSize.of(4),

View File

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

View File

@ -172,6 +172,8 @@ ggml
Minidump
collapseall
newfolder
Marijn
Haverbeke
unfocusable
unlocker
Tiktok