1
0
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:
Henry Heino
2025-06-06 02:10:11 -07:00
committed by GitHub
parent eb1970fd1a
commit a527a278a9
7 changed files with 457 additions and 227 deletions

View File

@@ -326,3 +326,5 @@ describe('markdownCommands', () => {
});
});

View File

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

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

View File

@@ -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',
' ![](./test.png)',
'',
'\t1. is',
'\t3. a test',
'\t4. a test',
'',
'1. A test',
].join('\n'),
after: [
'1. This',
' ![](./test.png)',
'',
'\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;

View File

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

View File

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

View File

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