Josh Scheitler 2025-04-19 17:17:24 +03:00 committed by GitHub
commit 6691dc479d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 185 deletions

View File

@ -2,10 +2,11 @@ import { EditorSelection } from '@codemirror/state';
import {
insertHorizontalRule,
insertOrIncreaseIndent,
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleList, toggleMath, updateLink,
} from './markdownCommands';
import createTestEditor from '../testUtil/createTestEditor';
import { blockMathTagName } from './MarkdownMathExtension';
import { ListType } from '../../types';
describe('markdownCommands', () => {
@ -324,5 +325,55 @@ describe('markdownCommands', () => {
expect(editor.state.doc.toString()).toBe('testing\n* * *\n* * *\n\n> this is a test\n> * * *\n> * * *');
});
it('should convert a nested bulleted list to an ordered list', async () => {
const initialDocText = [
'- Item 1',
' - Sub-item 1',
' - Sub-item 2',
'- Item 2',
].join('\n');
const expectedDocText = [
'1. Item 1',
' 1. Sub-item 1',
' 2. Sub-item 2',
'2. Item 2',
].join('\n');
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
['BulletList'],
);
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(expectedDocText);
});
it('should convert a mixed nested list to a bulleted list', async () => {
const initialDocText = `1. Item 1
1. Sub-item 1
2. Sub-item 2
2. Item 2`;
const expectedDocText = `- Item 1
- Sub-item 1
- Sub-item 2
- Item 2`;
const editor = await createTestEditor(
initialDocText,
EditorSelection.range(0, initialDocText.length),
['OrderedList'],
);
toggleList(ListType.UnorderedList)(editor);
expect(editor.state.doc.toString()).toBe(expectedDocText);
});
});

View File

@ -12,12 +12,9 @@ import toggleRegionFormatGlobally from '../utils/formatting/toggleRegionFormatGl
import { RegionSpec } from '../utils/formatting/RegionSpec';
import toggleInlineFormatGlobally from '../utils/formatting/toggleInlineFormatGlobally';
import stripBlockquote from './utils/stripBlockquote';
import isIndentationEquivalent from '../utils/formatting/isIndentationEquivalent';
import tabsToSpaces from '../utils/formatting/tabsToSpaces';
import renumberSelectedLists from './utils/renumberSelectedLists';
import toggleSelectedLinesStartWith from '../utils/formatting/toggleSelectedLinesStartWith';
const startingSpaceRegex = /^(\s*)/;
export const toggleBolded: Command = (view: EditorView): boolean => {
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
@ -122,15 +119,13 @@ export const toggleMath: Command = (view: EditorView): boolean => {
export const toggleList = (listType: ListType): Command => {
return (view: EditorView): boolean => {
let state = view.state;
let doc = state.doc;
const state = view.state;
const doc = state.doc;
// RegExps for different list types. The regular expressions MUST
// be mutually exclusive.
// `(?!\[[ xX]+\])` means "not followed by [x] or [ ]".
const bulletedRegex = /^\s*([-*])\s(?!\[[ xX]+\]\s)/;
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s/;
const numberedRegex = /^\s*\d+\.\s/;
const startingSpaceRegex = /^\s*/;
const listRegexes: Record<ListType, RegExp> = {
[ListType.OrderedList]: numberedRegex,
@ -138,180 +133,97 @@ export const toggleList = (listType: ListType): Command => {
[ListType.UnorderedList]: bulletedRegex,
};
const getContainerType = (line: Line): ListType|null => {
const getContainerType = (line: Line): ListType | null => {
const lineContent = stripBlockquote(line);
// Determine the container's type.
const checklistMatch = lineContent.match(checklistRegex);
const bulletListMatch = lineContent.match(bulletedRegex);
const orderedListMatch = lineContent.match(numberedRegex);
if (checklistMatch) {
return ListType.CheckList;
} else if (bulletListMatch) {
return ListType.UnorderedList;
} else if (orderedListMatch) {
return ListType.OrderedList;
}
if (lineContent.match(checklistRegex)) return ListType.CheckList;
if (lineContent.match(bulletedRegex)) return ListType.UnorderedList;
if (lineContent.match(numberedRegex)) return ListType.OrderedList;
return null;
};
const fromLine = doc.lineAt(state.selection.main.from);
const toLine = doc.lineAt(state.selection.main.to);
let baselineIndent = Infinity;
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
const line = doc.line(lineNum);
const content = stripBlockquote(line);
if (content.trim() !== '') {
const indent = (content.match(startingSpaceRegex)?.[0] || '').length;
baselineIndent = Math.min(baselineIndent, indent);
}
}
if (baselineIndent === Infinity) baselineIndent = 0;
let isEntirelyTargetList = true;
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
const line = doc.line(lineNum);
const content = stripBlockquote(line);
if (content.trim() === '' && listType === ListType.CheckList) {
isEntirelyTargetList = false;
break;
}
if (content.trim() === '') continue;
if (getContainerType(line) !== listType) {
isEntirelyTargetList = false;
break;
}
}
let outerCounter = 1;
const stack: { indent: number; counter: number }[] = [];
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
const changes: ChangeSpec[] = [];
let containerType: ListType|null = null;
// Total number of characters added (deleted if negative)
let charsAdded = 0;
const originalSel = sel;
let fromLine: Line;
let toLine: Line;
let firstLineIndentation: string;
let firstLineInBlockQuote: boolean;
let fromLineContent: string;
const computeSelectionProps = () => {
fromLine = doc.lineAt(sel.from);
toLine = doc.lineAt(sel.to);
fromLineContent = stripBlockquote(fromLine);
firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
firstLineInBlockQuote = (fromLineContent !== fromLine.text);
containerType = getContainerType(fromLine);
};
computeSelectionProps();
const origFirstLineIndentation = firstLineIndentation;
const origContainerType = containerType;
// Reset the selection if it seems likely the user didn't want the selection
// to be expanded
const isIndentationDiff =
!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
if (isIndentationDiff) {
const expandedRegionIndentation = firstLineIndentation;
sel = originalSel;
computeSelectionProps();
// Use the indentation level of the expanded region if it's greater.
// This makes sense in the case where unindented text is being converted to
// the same type of list as its container. For example,
// 1. Foobar
// unindented text
// that should be made a part of the above list.
//
// becoming
//
// 1. Foobar
// 2. unindented text
// 3. that should be made a part of the above list.
const wasGreaterIndentation = (
tabsToSpaces(state, expandedRegionIndentation).length
> tabsToSpaces(state, firstLineIndentation).length
);
if (wasGreaterIndentation) {
firstLineIndentation = expandedRegionIndentation;
}
} else if (
(origContainerType !== containerType && (origContainerType ?? null) !== null)
|| containerType !== getContainerType(toLine)
) {
// If the container type changed, this could be an artifact of checklists/bulleted
// lists sharing the same node type.
// Find the closest range of the same type of list to the original selection
let newFromLineNo = doc.lineAt(originalSel.from).number;
let newToLineNo = doc.lineAt(originalSel.to).number;
let lastFromLineNo;
let lastToLineNo;
while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
lastFromLineNo = newFromLineNo;
lastToLineNo = newToLineNo;
if (lastFromLineNo - 1 >= 1) {
const testFromLine = doc.line(lastFromLineNo - 1);
if (getContainerType(testFromLine) === origContainerType) {
newFromLineNo --;
}
}
if (lastToLineNo + 1 <= doc.lines) {
const testToLine = doc.line(lastToLineNo + 1);
if (getContainerType(testToLine) === origContainerType) {
newToLineNo ++;
}
}
}
sel = EditorSelection.range(
doc.line(newFromLineNo).from,
doc.line(newToLineNo).to,
);
computeSelectionProps();
}
// Determine whether the expanded selection should be empty
if (originalSel.empty && fromLine.number === toLine.number) {
sel = EditorSelection.cursor(toLine.to);
}
// Select entire lines (if not just a cursor)
if (!sel.empty) {
sel = EditorSelection.range(fromLine.from, toLine.to);
}
// Number of the item in the list (e.g. 2 for the 2nd item in the list)
let listItemCounter = 1;
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
const line = doc.line(lineNum);
const lineContent = stripBlockquote(line);
const lineContentFrom = line.to - lineContent.length;
const inBlockQuote = (lineContent !== line.text);
const indentation = lineContent.match(startingSpaceRegex)[0];
const wrongIndentation = !isIndentationEquivalent(state, indentation, firstLineIndentation);
// If not the right list level,
if (inBlockQuote !== firstLineInBlockQuote || wrongIndentation) {
// We'll be starting a new list
listItemCounter = 1;
const origLineContent = stripBlockquote(line);
if (origLineContent.trim() === '' && listType !== ListType.CheckList) {
continue;
}
// Don't add list numbers to otherwise empty lines (unless it's the first line)
if (lineNum !== fromLine.number && line.text.trim().length === 0) {
// Do not reset the counter -- the markdown renderer doesn't!
continue;
}
const lineContentFrom = line.to - origLineContent.length;
const indentation = origLineContent.match(startingSpaceRegex)?.[0] || '';
const currentIndent = indentation.length;
const normalizedIndent = currentIndent - baselineIndent;
const currentContainer = getContainerType(line);
const deleteFrom = lineContentFrom;
let deleteTo = deleteFrom + indentation.length;
// If we need to remove an existing list,
const currentContainer = getContainerType(line);
if (currentContainer !== null) {
const containerRegex = listRegexes[currentContainer];
const containerMatch = lineContent.match(containerRegex);
if (!containerMatch) {
throw new Error(
'Assertion failed: container regex does not match line content.',
);
const containerMatch = origLineContent.match(containerRegex);
if (containerMatch) {
deleteTo = lineContentFrom + containerMatch[0].length;
}
deleteTo = lineContentFrom + containerMatch[0].length;
}
let replacementString;
if (listType === containerType) {
// Delete the existing list if it's the same type as the current
replacementString = '';
} else if (listType === ListType.OrderedList) {
replacementString = `${firstLineIndentation}${listItemCounter}. `;
} else if (listType === ListType.CheckList) {
replacementString = `${firstLineIndentation}- [ ] `;
} else {
replacementString = `${firstLineIndentation}- `;
let replacementString = '';
if (!isEntirelyTargetList) {
if (listType === ListType.OrderedList) {
if (normalizedIndent <= 0) {
// Top-level item
stack.length = 0;
replacementString = `${indentation}${outerCounter}. `;
outerCounter++;
} else {
// Nested item
while (stack.length && stack[stack.length - 1].indent > currentIndent) {
stack.pop();
}
if (!stack.length || stack[stack.length - 1].indent < currentIndent) {
stack.push({ indent: currentIndent, counter: 1 });
}
const currentLevel = stack[stack.length - 1];
replacementString = `${indentation}${currentLevel.counter}. `;
currentLevel.counter++;
}
} else if (listType === ListType.CheckList) {
replacementString = `${indentation}- [ ] `;
} else if (listType === ListType.UnorderedList) {
replacementString = `${indentation}- `;
}
}
changes.push({
@ -321,32 +233,15 @@ export const toggleList = (listType: ListType): Command => {
});
charsAdded -= deleteTo - deleteFrom;
charsAdded += replacementString.length;
listItemCounter++;
}
// Don't change cursors to selections
if (sel.empty) {
// Position the cursor at the end of the last line modified
sel = EditorSelection.cursor(toLine.to + charsAdded);
} else {
sel = EditorSelection.range(
sel.from,
sel.to + charsAdded,
);
}
return {
changes,
range: sel,
};
const newSelection = sel.empty
? EditorSelection.cursor(toLine.to + charsAdded)
: EditorSelection.range(sel.from, sel.to + charsAdded);
return { changes, range: newSelection };
});
view.dispatch(changes);
state = view.state;
doc = state.doc;
// Renumber the list
view.dispatch(renumberSelectedLists(state));
return true;
};
};