You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
Desktop, Mobile: Resolves #11845: Adjust list toggle behavior for consistency with other apps (#12360)
This commit is contained in:
@@ -326,3 +326,5 @@ describe('markdownCommands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
@@ -63,34 +63,20 @@ describe('markdownCommands.toggleList', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not toggle a the full list when the cursor is on a blank line', async () => {
|
||||
const checklistStartText = [
|
||||
'# Test',
|
||||
'',
|
||||
'- [ ] This',
|
||||
'- [ ] is',
|
||||
'',
|
||||
].join('\n');
|
||||
it('should not toggle the full list when the cursor is on a blank line', async () => {
|
||||
const checklistStartText = ['- [ ] This', '- [ ] is'].join('\n');
|
||||
const checklistEndText = ['- [ ] a', '- [ ] test'].join('\n');
|
||||
|
||||
const checklistEndText = [
|
||||
'- [ ] a',
|
||||
'- [ ] test',
|
||||
].join('\n');
|
||||
const input = `${checklistStartText}\n\n${checklistEndText}`;
|
||||
const expected = `${checklistStartText}\n\n${checklistEndText}`; // no change
|
||||
|
||||
const editor = await createTestEditor(
|
||||
`${checklistStartText}\n${checklistEndText}`,
|
||||
|
||||
// Place the cursor on the blank line between the checklist
|
||||
// regions
|
||||
EditorSelection.cursor(unorderedListText.length + 1),
|
||||
['BulletList', 'ATXHeading1'],
|
||||
input,
|
||||
EditorSelection.cursor(checklistStartText.length + 1), // place cursor on the blank line
|
||||
['BulletList'],
|
||||
);
|
||||
|
||||
// Should create a checkbox on the blank line
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
`${checklistStartText}- [ ] \n${checklistEndText}`,
|
||||
);
|
||||
expect(editor.state.doc.toString()).toBe(expected);
|
||||
});
|
||||
|
||||
// it('should correctly replace an unordered list with a checklist', async () => {
|
||||
@@ -247,4 +233,237 @@ describe('markdownCommands.toggleList', () => {
|
||||
'- 192.168.1.1. This\n- 127.0.0.1. is\n- 0.0.0.0. a list',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve blank lines when toggling a checklist with blank lines', async () => {
|
||||
const listWithGaps = [
|
||||
'- A',
|
||||
'',
|
||||
'- B',
|
||||
'',
|
||||
'- C',
|
||||
].join('\n');
|
||||
|
||||
const expectedAfterToggle = [
|
||||
'- [ ] A',
|
||||
'',
|
||||
'- [ ] B',
|
||||
'',
|
||||
'- [ ] C',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(
|
||||
listWithGaps,
|
||||
EditorSelection.range(0, listWithGaps.length),
|
||||
['BulletList'],
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(expectedAfterToggle);
|
||||
});
|
||||
|
||||
it('should correctly toggle sublists within block quotes', async () => {
|
||||
const listInBlockQuote = `
|
||||
A block quote:
|
||||
> - This *
|
||||
> - is
|
||||
>
|
||||
> - a test. *
|
||||
>
|
||||
>
|
||||
>
|
||||
> - TEST
|
||||
> - Test *
|
||||
> - a
|
||||
> - test`.trim();
|
||||
const editor = await createTestEditor(
|
||||
listInBlockQuote,
|
||||
EditorSelection.range(
|
||||
'A block quote:'.length + 1,
|
||||
listInBlockQuote.length,
|
||||
),
|
||||
['BlockQuote', 'BulletList'],
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
expect(editor.state.doc.toString()).toBe(`
|
||||
A block quote:
|
||||
> 1. This *
|
||||
> 1. is
|
||||
>
|
||||
> 1. a test. *
|
||||
>
|
||||
>
|
||||
>
|
||||
> 1. TEST
|
||||
> 2. Test *
|
||||
> 2. a
|
||||
> 2. test
|
||||
`.trim());
|
||||
});
|
||||
|
||||
it('should correctly toggle sublists when there are multiple cursors', async () => {
|
||||
const testDocument = `
|
||||
- This (cursor)
|
||||
- is
|
||||
|
||||
- a test. (cursor)
|
||||
|
||||
|
||||
|
||||
- TEST
|
||||
- Test (cursor)
|
||||
- a (cursor)
|
||||
- test
|
||||
`.trim();
|
||||
|
||||
const getExpectedCursorLocations = (docText: string) => {
|
||||
return [...docText.matchAll(/\(cursor\)/g)]
|
||||
.map(match => match.index + match[0].length);
|
||||
};
|
||||
const initialCursors = getExpectedCursorLocations(testDocument)
|
||||
.map(location => EditorSelection.cursor(location));
|
||||
|
||||
const editor = await createTestEditor(
|
||||
testDocument,
|
||||
initialCursors,
|
||||
['BulletList'],
|
||||
);
|
||||
|
||||
toggleList(ListType.OrderedList)(editor);
|
||||
// Should renumber each line with a cursor separately
|
||||
expect(editor.state.doc.toString()).toBe(`
|
||||
1. This (cursor)
|
||||
- is
|
||||
|
||||
1. a test. (cursor)
|
||||
|
||||
|
||||
|
||||
- TEST
|
||||
2. Test (cursor)
|
||||
1. a (cursor)
|
||||
- test
|
||||
`.trim());
|
||||
expect(
|
||||
editor.state.selection.ranges.map(range => range.anchor),
|
||||
).toEqual(
|
||||
getExpectedCursorLocations(editor.state.doc.toString()),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should preserve non-list sub-items when changing list formatting', async () => {
|
||||
const initialDocText = `1. Item 1
|
||||
1. Sub-item 1
|
||||
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
2. Sub-item 2
|
||||
Not part of the list
|
||||
Also not part of the list
|
||||
2. Item 2`;
|
||||
|
||||
const expectedDocText = `- Item 1
|
||||
- Sub-item 1
|
||||
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
- Sub-item 2
|
||||
Not part of the list
|
||||
Also not part of the list
|
||||
- Item 2`;
|
||||
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(0, initialDocText.length),
|
||||
['OrderedList'],
|
||||
);
|
||||
|
||||
toggleList(ListType.UnorderedList)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(expectedDocText);
|
||||
});
|
||||
|
||||
it('should remove list formatting when toggling formatting in an existing list item', async () => {
|
||||
const initialDocText = `- [ ] Item 1
|
||||
- [ ] Sub-item 1
|
||||
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
- [ ] Sub-item 2
|
||||
Not part of the list
|
||||
Also not part of the list
|
||||
- [ ] Item 2`;
|
||||
|
||||
const expectedDocText = `Item 1
|
||||
Sub-item 1
|
||||
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\`
|
||||
Sub-item 2
|
||||
Not part of the list
|
||||
Also not part of the list
|
||||
Item 2`;
|
||||
|
||||
const editor = await createTestEditor(
|
||||
initialDocText,
|
||||
EditorSelection.range(0, initialDocText.length),
|
||||
['BulletList'],
|
||||
);
|
||||
|
||||
toggleList(ListType.CheckList)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(expectedDocText);
|
||||
});
|
||||
});
|
||||
|
@@ -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' });
|
||||
@@ -121,16 +118,20 @@ 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;
|
||||
enum ListAction {
|
||||
AddList,
|
||||
RemoveList,
|
||||
SwitchFormatting,
|
||||
}
|
||||
|
||||
return (view: EditorView): boolean => {
|
||||
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 +139,149 @@ 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 changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
let containerType: ListType|null = null;
|
||||
// Maximum line number in the original document that has
|
||||
// been processed
|
||||
let maximumChangedLine = -1;
|
||||
const getNextLineRange = (sel: SelectionRange) => {
|
||||
let fromLine = doc.lineAt(sel.from);
|
||||
const toLine = doc.lineAt(sel.to);
|
||||
|
||||
// Total number of characters added (deleted if negative)
|
||||
// Full selection already processed.
|
||||
if (toLine.number <= maximumChangedLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fromLine.number <= maximumChangedLine) {
|
||||
fromLine = doc.line(maximumChangedLine);
|
||||
}
|
||||
maximumChangedLine = toLine.number;
|
||||
|
||||
return { fromLine, toLine };
|
||||
};
|
||||
|
||||
const getIndent = (line: Line) => {
|
||||
const content = stripBlockquote(line);
|
||||
return (content.match(startingSpaceRegex)?.[0] || '').length;
|
||||
};
|
||||
|
||||
const getBaselineIndent = (fromLine: Line, toLine: Line) => {
|
||||
let baselineIndent = Infinity;
|
||||
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
|
||||
const line = doc.line(lineNum);
|
||||
const content = stripBlockquote(line);
|
||||
if (content.trim() !== '') {
|
||||
baselineIndent = Math.min(baselineIndent, getIndent(line));
|
||||
}
|
||||
}
|
||||
if (baselineIndent === Infinity) baselineIndent = 0;
|
||||
|
||||
return baselineIndent;
|
||||
};
|
||||
|
||||
const getFirstBaselineIndentLine = (fromLine: Line, toLine: Line) => {
|
||||
const baselineIndent = getBaselineIndent(fromLine, toLine);
|
||||
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum++) {
|
||||
const line = doc.line(lineNum);
|
||||
const content = stripBlockquote(line);
|
||||
if (content.trim() === '') continue;
|
||||
|
||||
const indent = getIndent(line);
|
||||
if (indent === baselineIndent) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return fromLine;
|
||||
};
|
||||
|
||||
const getAction = (fromLine: Line, toLine: Line) => {
|
||||
const firstLine = getFirstBaselineIndentLine(fromLine, toLine);
|
||||
|
||||
const currentListType = getContainerType(firstLine);
|
||||
if (currentListType === null) {
|
||||
return ListAction.AddList;
|
||||
} else if (currentListType === listType) {
|
||||
return ListAction.RemoveList;
|
||||
}
|
||||
return ListAction.SwitchFormatting;
|
||||
};
|
||||
|
||||
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
|
||||
const lineRange = getNextLineRange(sel);
|
||||
if (!lineRange) return { range: sel };
|
||||
const { fromLine, toLine } = lineRange;
|
||||
const baselineIndent = getBaselineIndent(fromLine, toLine);
|
||||
const action = getAction(fromLine, toLine);
|
||||
|
||||
// Outermost list item number
|
||||
let outerCounter = 1;
|
||||
// Stack mapping parent indentation to item numbers
|
||||
const stack: { indent: number; counter: number }[] = [];
|
||||
const changes: ChangeSpec[] = [];
|
||||
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;
|
||||
continue;
|
||||
const origLineContent = stripBlockquote(line);
|
||||
if (origLineContent.trim() === '') {
|
||||
continue; // skip blank lines
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Content excluding the block quote start
|
||||
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);
|
||||
let isAlreadyListItem = false;
|
||||
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;
|
||||
isAlreadyListItem = true;
|
||||
}
|
||||
|
||||
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 = indentation;
|
||||
if (action === ListAction.AddList || action === ListAction.SwitchFormatting) {
|
||||
if (action === ListAction.SwitchFormatting && !isAlreadyListItem) {
|
||||
// Skip replacement if the line didn't previously have list formatting
|
||||
deleteTo = deleteFrom;
|
||||
replacementString = '';
|
||||
} else 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,36 +291,24 @@ 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));
|
||||
view.dispatch(changes);
|
||||
// Fix any selected lists. Do this as a separate .dispatch
|
||||
// so that it can be undone separately.
|
||||
view.dispatch(renumberSelectedLists(view.state));
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const toggleHeaderLevel = (level: number): Command => {
|
||||
return (view: EditorView): boolean => {
|
||||
let headerStr = '';
|
||||
|
@@ -138,6 +138,49 @@ describe('renumberSelectedLists', () => {
|
||||
'\t5. test',
|
||||
].join('\n'),
|
||||
},
|
||||
{ // Should handle mixed number/bullet lists
|
||||
before: [
|
||||
'1. This',
|
||||
'\t- is',
|
||||
'\t\t',
|
||||
'',
|
||||
'\t\t1. Test',
|
||||
'\t1. A test',
|
||||
'- Test',
|
||||
].join('\n'),
|
||||
after: [
|
||||
'1. This',
|
||||
'\t- is',
|
||||
'\t\t',
|
||||
'',
|
||||
'\t\t1. Test',
|
||||
'\t1. A test',
|
||||
'- Test',
|
||||
].join('\n'),
|
||||
},
|
||||
{ // Should handle non-tight lists
|
||||
before: [
|
||||
'1. This',
|
||||
' ',
|
||||
'',
|
||||
'\t1. is',
|
||||
'\t3. a test',
|
||||
'\t4. a test',
|
||||
'',
|
||||
'1. A test',
|
||||
].join('\n'),
|
||||
after: [
|
||||
'1. This',
|
||||
' ',
|
||||
'',
|
||||
'\t1. is',
|
||||
'\t2. a test',
|
||||
'\t3. a test',
|
||||
'',
|
||||
'2. A test',
|
||||
].join('\n'),
|
||||
},
|
||||
|
||||
])('should handle nested lists (case %#)', async ({ before, after }) => {
|
||||
const suffix = '\n\n# End';
|
||||
before += suffix;
|
||||
|
@@ -39,15 +39,18 @@ const renumberSelectedLists = (state: EditorState): TransactionSpec => {
|
||||
prevLineNumber = line.number;
|
||||
|
||||
const filteredText = stripBlockquote(line);
|
||||
if (!filteredText.trim()) continue;
|
||||
|
||||
const match = filteredText.match(listItemRegex);
|
||||
|
||||
// Skip lines that aren't the correct type (e.g. blank lines)
|
||||
if (!match) {
|
||||
continue;
|
||||
let indentation;
|
||||
if (match) {
|
||||
indentation = match[1];
|
||||
} else {
|
||||
indentation = filteredText.match(/^\s+/)?.[0] ?? '';
|
||||
}
|
||||
|
||||
const indentation = match[1];
|
||||
|
||||
const indentationLen = tabsToSpaces(state, indentation).length;
|
||||
let currentGroupIndentLength = tabsToSpaces(state, currentGroupIndentation).length;
|
||||
const indentIncreased = indentationLen > currentGroupIndentLength;
|
||||
@@ -59,7 +62,9 @@ const renumberSelectedLists = (state: EditorState): TransactionSpec => {
|
||||
});
|
||||
nextListNumber = 1;
|
||||
} else if (indentDecreased) {
|
||||
nextListNumber = parseInt(match[2], 10);
|
||||
if (match) {
|
||||
nextListNumber = parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
// Handle the case where we deindent multiple times. For example,
|
||||
// 1. test
|
||||
@@ -78,19 +83,20 @@ const renumberSelectedLists = (state: EditorState): TransactionSpec => {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
currentGroupIndentation = indentation;
|
||||
|
||||
const from = line.to - filteredText.length;
|
||||
const to = from + match[0].length;
|
||||
const inserted = `${indentation}${nextListNumber}. `;
|
||||
nextListNumber++;
|
||||
if (match) {
|
||||
const from = line.to - filteredText.length;
|
||||
const to = from + match[0].length;
|
||||
const inserted = `${indentation}${nextListNumber}. `;
|
||||
nextListNumber++;
|
||||
|
||||
changes.push({
|
||||
from,
|
||||
to,
|
||||
insert: inserted,
|
||||
});
|
||||
changes.push({
|
||||
from,
|
||||
to,
|
||||
insert: inserted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Line } from '@codemirror/state';
|
||||
|
||||
const blockQuoteRegex = /^>\s/;
|
||||
const blockQuoteRegex = /^>(\s|$)/;
|
||||
|
||||
export const stripBlockquote = (line: Line): string => {
|
||||
const match = line.text.match(blockQuoteRegex);
|
||||
|
@@ -12,16 +12,18 @@ import MarkdownHighlightExtension from '../markdown/MarkdownHighlightExtension';
|
||||
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
|
||||
const createTestEditor = async (
|
||||
initialText: string,
|
||||
initialSelection: SelectionRange,
|
||||
initialSelection: SelectionRange|SelectionRange[],
|
||||
expectedSyntaxTreeTags: string[],
|
||||
extraExtensions: Extension[] = [],
|
||||
addMarkdownKeymap = true,
|
||||
): Promise<EditorView> => {
|
||||
await loadLanguages();
|
||||
|
||||
initialSelection = Array.isArray(initialSelection) ? initialSelection : [initialSelection];
|
||||
|
||||
const editor = new EditorView({
|
||||
doc: initialText,
|
||||
selection: EditorSelection.create([initialSelection]),
|
||||
selection: EditorSelection.create(initialSelection),
|
||||
extensions: [
|
||||
markdown({
|
||||
extensions: [MarkdownMathExtension, MarkdownHighlightExtension, GithubFlavoredMarkdownExt],
|
||||
|
Reference in New Issue
Block a user