Mobile,Desktop: Fixes #9200: Fix list renumbering and enable multiple selections (#9506)

pull/9498/head^2
Henry Heino 2023-12-13 11:48:06 -08:00 committed by GitHub
parent 6d6f9b3c3b
commit a730d349e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 147 additions and 49 deletions

View File

@ -7,7 +7,7 @@ import {
import { classHighlighter } from '@lezer/highlight';
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
} from '@codemirror/view';
import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands';
@ -203,7 +203,13 @@ const createEditor = (
};
},
} : undefined),
// Allows multiple selections and allows selecting a rectangle
// with ctrl (as in CodeMirror 5)
EditorState.allowMultipleSelections.of(true),
rectangularSelection(),
drawSelection(),
highlightSpecialChars(),
indentOnInput(),

View File

@ -8,7 +8,7 @@ import {
} from '@codemirror/state';
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
import {
RegionSpec, growSelectionToNode, renumberList,
RegionSpec, growSelectionToNode, renumberSelectedLists,
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
} from './markdownReformatter';
@ -354,9 +354,7 @@ export const toggleList = (listType: ListType): Command => {
doc = state.doc;
// Renumber the list
view.dispatch(state.changeByRange((sel: SelectionRange) => {
return renumberList(state, sel);
}));
view.dispatch(renumberSelectedLists(state));
return true;
};
@ -417,9 +415,7 @@ export const increaseIndent: Command = (view: EditorView): boolean => {
view.dispatch(changes);
// Fix any lists
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
return renumberList(view.state, sel);
}));
view.dispatch(renumberSelectedLists(view.state));
return true;
};
@ -468,9 +464,7 @@ export const decreaseIndent: Command = (view: EditorView): boolean => {
view.dispatch(changes);
// Fix any lists
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
return renumberList(view.state, sel);
}));
view.dispatch(renumberSelectedLists(view.state));
return true;
};

View File

@ -1,8 +1,9 @@
import {
findInlineMatch, MatchSide, RegionSpec, tabsToSpaces, toggleRegionFormatGlobally,
findInlineMatch, MatchSide, RegionSpec, renumberSelectedLists, tabsToSpaces, toggleRegionFormatGlobally,
} from './markdownReformatter';
import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state';
import { indentUnit } from '@codemirror/language';
import createTestEditor from '../testUtil/createTestEditor';
describe('markdownReformatter', () => {
@ -142,4 +143,55 @@ describe('markdownReformatter', () => {
expect(tabsToSpaces(state, '\t ')).toBe(' ');
expect(tabsToSpaces(state, ' \t ')).toBe(' ');
});
it('should correctly renumber a list with multiple selections', async () => {
const firstListText = [
'1. This',
'\t2. is',
'\t3. a',
'4. test',
'',
'',
].join('\n');
const secondListText = [
'## List 2',
'',
'1. Test',
'\t2. 2',
].join('\n');
const editor = await createTestEditor(
`${firstListText}${secondListText}\n\n# End`,
EditorSelection.cursor(firstListText.length + secondListText.length),
['OrderedList', 'ATXHeading1', 'ATXHeading2'],
);
// Include a selection twice in the same list -- previously,
const initialSelection = EditorSelection.create([
EditorSelection.cursor('1. This\n2.'.length), // Middle of second line
EditorSelection.cursor('1. This\n2. is\n3'.length), // Beginning of third line
EditorSelection.cursor(firstListText.length + secondListText.length - 1), // End
]);
editor.dispatch({
selection: initialSelection,
});
editor.dispatch(renumberSelectedLists(editor.state));
expect(editor.state.doc.toString()).toBe([
'1. This',
'\t1. is',
'\t2. a',
'2. test',
'',
'## List 2',
'',
'1. Test',
'\t1. 2',
'',
'# End',
].join('\n'));
});
});

View File

@ -619,20 +619,22 @@ export const toggleSelectedLinesStartWith = (
};
// Ensures that ordered lists within [sel] are numbered in ascending order.
export const renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => {
export const renumberSelectedLists = (state: EditorState): TransactionSpec => {
const doc = state.doc;
const listItemRegex = /^(\s*)(\d+)\.\s?/;
const changes: ChangeSpec[] = [];
const fromLine = doc.lineAt(sel.from);
const toLine = doc.lineAt(sel.to);
let charsAdded = 0;
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
const handleLines = (linesToHandle: Line[]) => {
const changes: ChangeSpec[] = [];
type ListItemRecord = {
nextListNumber: number;
indentationLength: number;
};
const listNumberStack: ListItemRecord[] = [];
let currentGroupIndentation = '';
let nextListNumber = 1;
const listNumberStack: number[] = [];
let prevLineNumber;
for (const line of linesToHandle) {
@ -644,15 +646,38 @@ export const renumberList = (state: EditorState, sel: SelectionRange): Selection
const filteredText = stripBlockquote(line);
const match = filteredText.match(listItemRegex);
// Skip lines that aren't the correct type (e.g. blank lines)
if (!match) {
continue;
}
const indentation = match[1];
const indentationLen = tabsToSpaces(state, indentation).length;
const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
let targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
if (targetIndentLen < indentationLen) {
listNumberStack.push(nextListNumber);
listNumberStack.push({ nextListNumber, indentationLength: indentationLen });
nextListNumber = 1;
} else if (targetIndentLen > indentationLen) {
nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10);
nextListNumber = parseInt(match[2], 10);
// Handle the case where we deindent multiple times. For example,
// 1. test
// 1. test
// 1. test
// 2. test
while (targetIndentLen > indentationLen) {
const listNumberRecord = listNumberStack.pop();
if (!listNumberRecord) {
break;
} else {
targetIndentLen = listNumberRecord.indentationLength;
nextListNumber = listNumberRecord.nextListNumber;
}
}
}
if (targetIndentLen !== indentationLen) {
@ -669,44 +694,65 @@ export const renumberList = (state: EditorState, sel: SelectionRange): Selection
to,
insert: inserted,
});
charsAdded -= to - from;
charsAdded += inserted.length;
}
return changes;
};
const linesToHandle: Line[] = [];
syntaxTree(state).iterate({
from: sel.from,
to: sel.to,
enter: (nodeRef: SyntaxNodeRef) => {
if (nodeRef.name === 'ListItem') {
for (const node of nodeRef.node.parent.getChildren('ListItem')) {
const line = doc.lineAt(node.from);
const filteredText = stripBlockquote(line);
const match = filteredText.match(listItemRegex);
if (match) {
linesToHandle.push(line);
// Find all selected lists
const selectedListRanges: SelectionRange[] = [];
for (const selection of state.selection.ranges) {
const listLines: Line[] = [];
syntaxTree(state).iterate({
from: selection.from,
to: selection.to,
enter: (nodeRef: SyntaxNodeRef) => {
if (nodeRef.name === 'ListItem') {
for (const node of nodeRef.node.parent.getChildren('ListItem')) {
const line = doc.lineAt(node.from);
const filteredText = stripBlockquote(line);
const match = filteredText.match(listItemRegex);
if (match) {
listLines.push(line);
}
}
}
},
});
listLines.sort((a, b) => a.number - b.number);
if (listLines.length > 0) {
const fromLine = listLines[0];
const toLine = listLines[listLines.length - 1];
selectedListRanges.push(
EditorSelection.range(fromLine.from, toLine.to),
);
}
}
const changes: ChangeSpec[] = [];
if (selectedListRanges.length > 0) {
// Use EditorSelection.create to merge overlapping lists
const listsToHandle = EditorSelection.create(selectedListRanges).ranges;
for (const listSelection of listsToHandle) {
const lines = [];
const startLine = doc.lineAt(listSelection.from);
const endLine = doc.lineAt(listSelection.to);
for (let i = startLine.number; i <= endLine.number; i++) {
lines.push(doc.line(i));
}
},
});
linesToHandle.sort((a, b) => a.number - b.number);
handleLines(linesToHandle);
// Re-position the selection in a way that makes sense
if (sel.empty) {
sel = EditorSelection.cursor(toLine.to + charsAdded);
} else {
sel = EditorSelection.range(
fromLine.from,
toLine.to + charsAdded,
);
changes.push(...handleLines(lines));
}
}
return {
range: sel,
changes,
};
};