feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,31 @@
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
|
||||
const additionalAliases: Record<string, string[]> = {
|
||||
'python': ['py'],
|
||||
'rust': ['rs'],
|
||||
'markdown': ['md'],
|
||||
};
|
||||
|
||||
// Convert supportedLanguages to a CodeMirror-readable list
|
||||
// of LanguageDescriptions
|
||||
export const allLanguages: LanguageDescription[] = [];
|
||||
|
||||
for (const language of languages) {
|
||||
const languageId = language.name.toLowerCase();
|
||||
|
||||
if (additionalAliases.hasOwnProperty(languageId)) {
|
||||
allLanguages.push(LanguageDescription.of({
|
||||
name: language.name,
|
||||
alias: [...language.alias, ...additionalAliases[languageId]],
|
||||
extensions: language.extensions,
|
||||
filename: language.filename,
|
||||
support: language.support,
|
||||
load: () => language.load.call(language),
|
||||
}));
|
||||
} else {
|
||||
allLanguages.push(language);
|
||||
}
|
||||
}
|
||||
|
||||
export default allLanguages;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language';
|
||||
|
||||
// To allow auto-indent to work in an unrecognised language, we define an
|
||||
// empty language. Doing so seems to enable auto-indent in code blocks with
|
||||
// that language.
|
||||
const defaultLanguage = StreamLanguage.define({
|
||||
token: (stream) => {
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const defaultLanguageDescription = LanguageDescription.of({
|
||||
name: 'default',
|
||||
support: new LanguageSupport(defaultLanguage),
|
||||
});
|
||||
|
||||
export default defaultLanguageDescription;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import defaultLanguage from './defaultLanguage';
|
||||
|
||||
// Intended for use by the `markdown({ ... })` extension.
|
||||
const lookUpLanguage = (languageInfo: string) => {
|
||||
return LanguageDescription.matchLanguageName(languages, languageInfo) ?? defaultLanguage;
|
||||
};
|
||||
|
||||
export default lookUpLanguage;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Tree } from '@lezer/common';
|
||||
|
||||
const getCheckboxAtPosition = (pos: number, tree: Tree) => {
|
||||
let iterator = tree.resolveStack(pos);
|
||||
|
||||
while (true) {
|
||||
if (iterator.node.name === 'TaskMarker') {
|
||||
return iterator.node;
|
||||
}
|
||||
|
||||
if (!iterator.next) {
|
||||
break;
|
||||
} else {
|
||||
iterator = iterator.next;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default getCheckboxAtPosition;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import renumberSelectedLists from './renumberSelectedLists';
|
||||
|
||||
describe('renumberSelectedLists', () => {
|
||||
jest.retryTimes(2);
|
||||
|
||||
it('should correctly renumber a list with multiple selections in that list', async () => {
|
||||
const listText = [
|
||||
'1. This',
|
||||
'\t2. is',
|
||||
'\t3. a',
|
||||
'4. test',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(
|
||||
`${listText}\n\n# End`,
|
||||
EditorSelection.cursor(listText.length),
|
||||
['OrderedList', 'ATXHeading1', 'ATXHeading2'],
|
||||
);
|
||||
|
||||
// Include a selection twice in the same list
|
||||
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
|
||||
]);
|
||||
|
||||
editor.dispatch({
|
||||
selection: initialSelection,
|
||||
});
|
||||
|
||||
editor.dispatch(renumberSelectedLists(editor.state));
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'1. This',
|
||||
'\t1. is',
|
||||
'\t2. a',
|
||||
'2. test',
|
||||
'',
|
||||
'# End',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('should preserve the first list number if not 1', async () => {
|
||||
const listText = [
|
||||
'2. This',
|
||||
'4. is',
|
||||
'5. a',
|
||||
'6. test',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(
|
||||
`${listText}\n\n# End`,
|
||||
EditorSelection.range(0, listText.length),
|
||||
['OrderedList', 'ATXHeading1'],
|
||||
);
|
||||
|
||||
editor.dispatch(renumberSelectedLists(editor.state));
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'2. This',
|
||||
'3. is',
|
||||
'4. a',
|
||||
'5. test',
|
||||
'',
|
||||
'# End',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
// Should handle the case where a single item is over-indented
|
||||
before: [
|
||||
'- This',
|
||||
'- is',
|
||||
'\t1. a',
|
||||
'\t\t2. test',
|
||||
'\t3. of',
|
||||
'\t4. lists',
|
||||
].join('\n'),
|
||||
after: [
|
||||
'- This',
|
||||
'- is',
|
||||
'\t1. a',
|
||||
'\t\t1. test',
|
||||
'\t2. of',
|
||||
'\t3. lists',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
// Should handle the case where multiple sublists need to be renumbered
|
||||
before: [
|
||||
'- This',
|
||||
'- is',
|
||||
'\t1. a',
|
||||
'\t\t2. test',
|
||||
'\t3. of',
|
||||
'\t\t4. lists',
|
||||
'\t\t5. lists',
|
||||
'\t\t6. lists',
|
||||
'\t7. lists',
|
||||
'',
|
||||
'',
|
||||
'1. New list',
|
||||
'\t3. Item',
|
||||
].join('\n'),
|
||||
after: [
|
||||
'- This',
|
||||
'- is',
|
||||
'\t1. a',
|
||||
'\t\t1. test',
|
||||
'\t2. of',
|
||||
'\t\t1. lists',
|
||||
'\t\t2. lists',
|
||||
'\t\t3. lists',
|
||||
'\t3. lists',
|
||||
'',
|
||||
'',
|
||||
'1. New list',
|
||||
'\t1. Item',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
before: [
|
||||
'2. This',
|
||||
'\t1. is',
|
||||
'\t2. a',
|
||||
'\t\t3. test',
|
||||
'\t4. test',
|
||||
'\t5. test',
|
||||
'\t6. test',
|
||||
].join('\n'),
|
||||
after: [
|
||||
'2. This',
|
||||
'\t1. is',
|
||||
'\t2. a',
|
||||
'\t\t1. test',
|
||||
'\t3. test',
|
||||
'\t4. test',
|
||||
'\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;
|
||||
after += suffix;
|
||||
const editor = await createTestEditor(
|
||||
before,
|
||||
EditorSelection.range(0, before.length - suffix.length),
|
||||
['OrderedList', 'ATXHeading1'],
|
||||
);
|
||||
|
||||
editor.dispatch(renumberSelectedLists(editor.state));
|
||||
|
||||
expect(editor.state.doc.toString()).toBe(after);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ChangeSpec, EditorSelection, EditorState, Line, SelectionRange, TransactionSpec } from '@codemirror/state';
|
||||
import stripBlockquote from './stripBlockquote';
|
||||
import tabsToSpaces from '../../utils/formatting/tabsToSpaces';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
// Ensures that ordered lists within [sel] are numbered in ascending order.
|
||||
const renumberSelectedLists = (state: EditorState): TransactionSpec => {
|
||||
const doc = state.doc;
|
||||
|
||||
const listItemRegex = /^(\s*)(\d+)\.\s?/;
|
||||
|
||||
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
|
||||
const handleLines = (linesToHandle: Line[]) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
if (linesToHandle.length === 0) {
|
||||
return changes;
|
||||
}
|
||||
|
||||
let firstListNumber = Number(listItemRegex.exec(linesToHandle[0].text)?.[2]);
|
||||
if (!isFinite(firstListNumber) || firstListNumber < 1) {
|
||||
firstListNumber = 1;
|
||||
}
|
||||
|
||||
type ListItemRecord = {
|
||||
nextListNumber: number;
|
||||
indentationLength: number;
|
||||
};
|
||||
const listNumberStack: ListItemRecord[] = [];
|
||||
let currentGroupIndentation = '';
|
||||
let nextListNumber = firstListNumber;
|
||||
let prevLineNumber;
|
||||
|
||||
for (const line of linesToHandle) {
|
||||
// Don't re-handle lines.
|
||||
if (line.number === prevLineNumber) {
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
let indentation;
|
||||
if (match) {
|
||||
indentation = match[1];
|
||||
} else {
|
||||
indentation = filteredText.match(/^\s+/)?.[0] ?? '';
|
||||
}
|
||||
|
||||
const indentationLen = tabsToSpaces(state, indentation).length;
|
||||
let currentGroupIndentLength = tabsToSpaces(state, currentGroupIndentation).length;
|
||||
const indentIncreased = indentationLen > currentGroupIndentLength;
|
||||
const indentDecreased = indentationLen < currentGroupIndentLength;
|
||||
if (indentIncreased) {
|
||||
// Save the state of the previous group so that it can be restored later.
|
||||
listNumberStack.push({
|
||||
nextListNumber, indentationLength: currentGroupIndentLength,
|
||||
});
|
||||
nextListNumber = 1;
|
||||
} else if (indentDecreased) {
|
||||
if (match) {
|
||||
nextListNumber = parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
// Handle the case where we deindent multiple times. For example,
|
||||
// 1. test
|
||||
// 1. test
|
||||
// 1. test
|
||||
// 2. test
|
||||
while (indentationLen < currentGroupIndentLength) {
|
||||
const listNumberRecord = listNumberStack.pop();
|
||||
|
||||
if (!listNumberRecord) {
|
||||
break;
|
||||
} else {
|
||||
currentGroupIndentLength = listNumberRecord.indentationLength;
|
||||
nextListNumber = listNumberRecord.nextListNumber;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
currentGroupIndentation = indentation;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
changes.push(...handleLines(lines));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
};
|
||||
};
|
||||
|
||||
export default renumberSelectedLists;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Line } from '@codemirror/state';
|
||||
|
||||
const blockQuoteRegex = /^>(\s|$)/;
|
||||
|
||||
export const stripBlockquote = (line: Line): string => {
|
||||
const match = line.text.match(blockQuoteRegex);
|
||||
|
||||
if (match) {
|
||||
return line.text.substring(match[0].length);
|
||||
}
|
||||
|
||||
return line.text;
|
||||
};
|
||||
|
||||
export default stripBlockquote;
|
||||
@@ -0,0 +1,345 @@
|
||||
import { parseTable, serializeTable, addRow, addColumn, deleteRow, deleteColumn, generateTable, ColumnAlignment } from './tableUtils';
|
||||
|
||||
describe('tableUtils', () => {
|
||||
|
||||
// parseTable
|
||||
it('should parse a simple 2x2 table', () => {
|
||||
const text = [
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.header.cells).toHaveLength(2);
|
||||
expect(table!.header.cells[0].content).toBe('A');
|
||||
expect(table!.header.cells[1].content).toBe('B');
|
||||
expect(table!.body).toHaveLength(1);
|
||||
expect(table!.body[0].cells[0].content).toBe('1');
|
||||
expect(table!.body[0].cells[1].content).toBe('2');
|
||||
});
|
||||
|
||||
it('should parse alignment markers', () => {
|
||||
const text = [
|
||||
'| Left | Center | Right | None |',
|
||||
'|:-----|:------:|------:|------|',
|
||||
'| a | b | c | d |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.alignments[0]).toBe(ColumnAlignment.Left);
|
||||
expect(table!.alignments[1]).toBe(ColumnAlignment.Center);
|
||||
expect(table!.alignments[2]).toBe(ColumnAlignment.Right);
|
||||
expect(table!.alignments[3]).toBe(ColumnAlignment.None);
|
||||
});
|
||||
|
||||
it('should parse a table with multiple body rows', () => {
|
||||
const text = [
|
||||
'| H1 | H2 |',
|
||||
'|----|-----|',
|
||||
'| a | b |',
|
||||
'| c | d |',
|
||||
'| e | f |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.body).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return null for invalid input', () => {
|
||||
expect(parseTable('hello world')).toBeNull();
|
||||
expect(parseTable('| A |\n| B |')).toBeNull(); // no delimiter
|
||||
expect(parseTable('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle header-only tables (no body rows)', () => {
|
||||
const text = [
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should normalize rows with fewer cells than header', () => {
|
||||
const text = [
|
||||
'| A | B | C |',
|
||||
'|---|---|---|',
|
||||
'| 1 |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.body[0].cells).toHaveLength(3);
|
||||
expect(table!.body[0].cells[0].content).toBe('1');
|
||||
expect(table!.body[0].cells[1].content).toBe('');
|
||||
expect(table!.body[0].cells[2].content).toBe('');
|
||||
});
|
||||
|
||||
// serializeTable
|
||||
it('should produce valid Markdown output when serializing', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = serializeTable(table!);
|
||||
const reparsed = parseTable(result);
|
||||
expect(reparsed).not.toBeNull();
|
||||
expect(reparsed!.header.cells[0].content).toBe('A');
|
||||
expect(reparsed!.header.cells[1].content).toBe('B');
|
||||
expect(reparsed!.body[0].cells[0].content).toBe('1');
|
||||
expect(reparsed!.body[0].cells[1].content).toBe('2');
|
||||
});
|
||||
|
||||
it('should preserve alignment markers when serializing', () => {
|
||||
const text = [
|
||||
'| Left | Center | Right |',
|
||||
'|:-----|:------:|------:|',
|
||||
'| a | b | c |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(text);
|
||||
const result = serializeTable(table!);
|
||||
|
||||
const reparsed = parseTable(result);
|
||||
expect(reparsed).not.toBeNull();
|
||||
expect(reparsed!.alignments[0]).toBe(ColumnAlignment.Left);
|
||||
expect(reparsed!.alignments[1]).toBe(ColumnAlignment.Center);
|
||||
expect(reparsed!.alignments[2]).toBe(ColumnAlignment.Right);
|
||||
});
|
||||
|
||||
it('should round-trip: parse then serialize preserves content', () => {
|
||||
const original = [
|
||||
'| Name | Age | City |',
|
||||
'|------|-----|---------|',
|
||||
'| Alice | 30 | NYC |',
|
||||
'| Bob | 25 | London |',
|
||||
].join('\n');
|
||||
|
||||
const table = parseTable(original);
|
||||
const serialized = serializeTable(table!);
|
||||
const reparsed = parseTable(serialized);
|
||||
|
||||
expect(reparsed).not.toBeNull();
|
||||
expect(reparsed!.header.cells[0].content).toBe('Name');
|
||||
expect(reparsed!.body[0].cells[0].content).toBe('Alice');
|
||||
expect(reparsed!.body[1].cells[2].content).toBe('London');
|
||||
});
|
||||
|
||||
it('should pad columns for alignment when serializing', () => {
|
||||
const table = parseTable([
|
||||
'| A | LongHeader |',
|
||||
'|---|---|',
|
||||
'| x | y |',
|
||||
].join('\n'));
|
||||
|
||||
const result = serializeTable(table!);
|
||||
const lines = result.split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
const pipeCount = (s: string) => (s.match(/\|/g) || []).length;
|
||||
expect(pipeCount(lines[0])).toBe(pipeCount(lines[1]));
|
||||
expect(pipeCount(lines[1])).toBe(pipeCount(lines[2]));
|
||||
});
|
||||
|
||||
// addRow
|
||||
it('should add a row after the specified index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
'| 3 | 4 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = addRow(table!, 0);
|
||||
expect(result.body).toHaveLength(3);
|
||||
expect(result.body[0].cells[0].content).toBe('1');
|
||||
expect(result.body[1].cells[0].content).toBe(''); // new row
|
||||
expect(result.body[2].cells[0].content).toBe('3');
|
||||
});
|
||||
|
||||
it('should add a row at the beginning with afterIndex = -1', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = addRow(table!, -1);
|
||||
expect(result.body).toHaveLength(2);
|
||||
expect(result.body[0].cells[0].content).toBe(''); // new row
|
||||
expect(result.body[1].cells[0].content).toBe('1');
|
||||
});
|
||||
|
||||
it('should add a row with correct column count', () => {
|
||||
const table = parseTable([
|
||||
'| A | B | C |',
|
||||
'|---|---|---|',
|
||||
'| 1 | 2 | 3 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = addRow(table!, 0);
|
||||
expect(result.body[1].cells).toHaveLength(3);
|
||||
});
|
||||
|
||||
// addColumn
|
||||
it('should add a column after the specified index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = addColumn(table!, 0);
|
||||
expect(result.header.cells).toHaveLength(3);
|
||||
expect(result.header.cells[0].content).toBe('A');
|
||||
expect(result.header.cells[1].content).toBe(''); // new column
|
||||
expect(result.header.cells[2].content).toBe('B');
|
||||
expect(result.alignments).toHaveLength(3);
|
||||
expect(result.body[0].cells).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should add a column at the beginning with afterIndex = -1', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = addColumn(table!, -1);
|
||||
expect(result.header.cells[0].content).toBe(''); // new column
|
||||
expect(result.header.cells[1].content).toBe('A');
|
||||
});
|
||||
|
||||
// deleteRow
|
||||
it('should delete the row at the given index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
'| 3 | 4 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = deleteRow(table!, 0);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.body).toHaveLength(1);
|
||||
expect(result!.body[0].cells[0].content).toBe('3');
|
||||
});
|
||||
|
||||
it('deleteRow should return null for invalid index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(deleteRow(table!, -1)).toBeNull();
|
||||
expect(deleteRow(table!, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow deleting all body rows (header-only table)', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = deleteRow(table!, 0);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
// deleteColumn
|
||||
it('should delete the column at the given index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B | C |',
|
||||
'|---|---|---|',
|
||||
'| 1 | 2 | 3 |',
|
||||
].join('\n'));
|
||||
|
||||
const result = deleteColumn(table!, 1);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.header.cells).toHaveLength(2);
|
||||
expect(result!.header.cells[0].content).toBe('A');
|
||||
expect(result!.header.cells[1].content).toBe('C');
|
||||
expect(result!.body[0].cells).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return null when deleting the last column', () => {
|
||||
const table = parseTable([
|
||||
'| A |',
|
||||
'|---|',
|
||||
'| 1 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(deleteColumn(table!, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteColumn should return null for invalid index', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(deleteColumn(table!, -1)).toBeNull();
|
||||
expect(deleteColumn(table!, 5)).toBeNull();
|
||||
});
|
||||
|
||||
// generateTable
|
||||
it('should generate a valid table with the specified dimensions', () => {
|
||||
const result = generateTable(2, 3);
|
||||
const table = parseTable(result);
|
||||
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.header.cells).toHaveLength(3);
|
||||
expect(table!.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should generate a header-only table with 0 body rows', () => {
|
||||
const result = generateTable(0, 2);
|
||||
const table = parseTable(result);
|
||||
|
||||
expect(table).not.toBeNull();
|
||||
expect(table!.header.cells).toHaveLength(2);
|
||||
expect(table!.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject non-integer dimensions', () => {
|
||||
expect(() => generateTable(1.5, 2)).toThrow();
|
||||
expect(() => generateTable(2, 2.5)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid dimensions', () => {
|
||||
expect(() => generateTable(0, 0)).toThrow();
|
||||
expect(() => generateTable(-1, 2)).toThrow();
|
||||
});
|
||||
|
||||
it('addRow should reject out-of-range afterIndex', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(() => addRow(table!, -2)).toThrow(RangeError);
|
||||
expect(() => addRow(table!, 5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('addColumn should reject out-of-range afterIndex', () => {
|
||||
const table = parseTable([
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'));
|
||||
|
||||
expect(() => addColumn(table!, -2)).toThrow(RangeError);
|
||||
expect(() => addColumn(table!, 5)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// Pure utility functions for parsing and manipulating Markdown tables.
|
||||
// No CodeMirror dependencies — all operations work on plain strings.
|
||||
|
||||
export interface TableCell {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TableRow {
|
||||
cells: TableCell[];
|
||||
}
|
||||
|
||||
export enum ColumnAlignment {
|
||||
Left = 'left',
|
||||
Right = 'right',
|
||||
Center = 'center',
|
||||
None = 'none',
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
header: TableRow;
|
||||
alignments: ColumnAlignment[];
|
||||
body: TableRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Split a single table row line into cell contents.
|
||||
// Check if a pipe at position `i` in `text` is unescaped.
|
||||
// A pipe is escaped if preceded by an odd number of backslashes.
|
||||
const isUnescapedPipe = (text: string, i: number): boolean => {
|
||||
if (text[i] !== '|') return false;
|
||||
let backslashes = 0;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && text[j] === '\\') {
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
return backslashes % 2 === 0;
|
||||
};
|
||||
|
||||
// Handles leading/trailing pipes and trims whitespace from each cell.
|
||||
//
|
||||
// Example: "| foo | bar |" → ["foo", "bar"]
|
||||
const splitRow = (line: string): string[] => {
|
||||
// Remove leading/trailing pipe and whitespace
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
|
||||
if (trimmed.length > 0 && isUnescapedPipe(trimmed, trimmed.length - 1)) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
|
||||
// Split on unescaped pipes only
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (isUnescapedPipe(trimmed, i)) {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += trimmed[i];
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
};
|
||||
|
||||
// Parse a delimiter cell (e.g. "---", ":---", "---:", ":---:") into a ColumnAlignment.
|
||||
const parseAlignment = (cell: string): ColumnAlignment => {
|
||||
const trimmed = cell.trim();
|
||||
const left = trimmed.startsWith(':');
|
||||
const right = trimmed.endsWith(':');
|
||||
|
||||
if (left && right) return ColumnAlignment.Center;
|
||||
if (right) return ColumnAlignment.Right;
|
||||
if (left) return ColumnAlignment.Left;
|
||||
return ColumnAlignment.None;
|
||||
};
|
||||
|
||||
// Check whether a line looks like a table delimiter row.
|
||||
// A delimiter row contains only pipes, dashes, colons, and whitespace.
|
||||
const isDelimiterRow = (line: string): boolean => {
|
||||
const trimmed = line.trim();
|
||||
// Must contain at least one dash
|
||||
if (!trimmed.includes('-')) return false;
|
||||
// Must only contain valid delimiter characters
|
||||
return /^[|\-:\s]+$/.test(trimmed);
|
||||
};
|
||||
|
||||
// Parse a Markdown table string into a structured Table object.
|
||||
// Returns null if the text is not a valid Markdown table.
|
||||
//
|
||||
// Expected format:
|
||||
// | Header1 | Header2 |
|
||||
// |---------|---------|
|
||||
// | Cell1 | Cell2 |
|
||||
export const parseTable = (text: string): Table | null => {
|
||||
const lines = text.split('\n').filter(line => line.trim().length > 0);
|
||||
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const headerCells = splitRow(lines[0]);
|
||||
if (headerCells.length === 0) return null;
|
||||
|
||||
// Second line must be the delimiter row
|
||||
if (!isDelimiterRow(lines[1])) return null;
|
||||
|
||||
const delimiterCells = splitRow(lines[1]);
|
||||
if (delimiterCells.length !== headerCells.length) return null;
|
||||
|
||||
const alignments = delimiterCells.map(parseAlignment);
|
||||
|
||||
const header: TableRow = {
|
||||
cells: headerCells.map(content => ({ content })),
|
||||
};
|
||||
|
||||
const body: TableRow[] = [];
|
||||
for (let i = 2; i < lines.length; i++) {
|
||||
const cells = splitRow(lines[i]);
|
||||
// Pad or trim cells to match header column count
|
||||
const normalizedCells: TableCell[] = [];
|
||||
for (let j = 0; j < headerCells.length; j++) {
|
||||
normalizedCells.push({ content: j < cells.length ? cells[j] : '' });
|
||||
}
|
||||
body.push({ cells: normalizedCells });
|
||||
}
|
||||
|
||||
return { header, alignments, body };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Compute the display width of each column based on the widest cell content.
|
||||
// Minimum width is 3 (for the delimiter "---").
|
||||
const computeColumnWidths = (table: Table): number[] => {
|
||||
const colCount = table.header.cells.length;
|
||||
const widths: number[] = new Array(colCount).fill(3);
|
||||
|
||||
for (let col = 0; col < colCount; col++) {
|
||||
widths[col] = Math.max(widths[col], table.header.cells[col].content.length);
|
||||
for (const row of table.body) {
|
||||
if (col < row.cells.length) {
|
||||
widths[col] = Math.max(widths[col], row.cells[col].content.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widths;
|
||||
};
|
||||
|
||||
// Pad a cell's content to the target width based on alignment.
|
||||
const padCell = (content: string, width: number, alignment: ColumnAlignment): string => {
|
||||
const padAmount = width - content.length;
|
||||
if (padAmount <= 0) return content;
|
||||
|
||||
switch (alignment) {
|
||||
case ColumnAlignment.Right:
|
||||
return `${' '.repeat(padAmount)}${content}`;
|
||||
case ColumnAlignment.Center: {
|
||||
const leftPad = Math.floor(padAmount / 2);
|
||||
const rightPad = padAmount - leftPad;
|
||||
return `${' '.repeat(leftPad)}${content}${' '.repeat(rightPad)}`;
|
||||
}
|
||||
default:
|
||||
return `${content}${' '.repeat(padAmount)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Build a delimiter string for a column, e.g. "---", ":---", "---:", ":---:"
|
||||
const buildDelimiter = (width: number, alignment: ColumnAlignment): string => {
|
||||
switch (alignment) {
|
||||
case ColumnAlignment.Left:
|
||||
return `:${'-'.repeat(width - 1)}`;
|
||||
case ColumnAlignment.Right:
|
||||
return `${'-'.repeat(width - 1)}:`;
|
||||
case ColumnAlignment.Center:
|
||||
return `:${'-'.repeat(width - 2)}:`;
|
||||
default:
|
||||
return '-'.repeat(width);
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize a row of cells into a Markdown table line.
|
||||
const serializeRow = (cells: TableCell[], widths: number[], alignments: ColumnAlignment[]): string => {
|
||||
const parts = cells.map((cell, i) => {
|
||||
return ` ${padCell(cell.content, widths[i], alignments[i])} `;
|
||||
});
|
||||
return `|${parts.join('|')}|`;
|
||||
};
|
||||
|
||||
// Convert a Table object back into a Markdown table string.
|
||||
// Columns are padded to align pipes.
|
||||
export const serializeTable = (table: Table): string => {
|
||||
const widths = computeColumnWidths(table);
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header row
|
||||
lines.push(serializeRow(table.header.cells, widths, table.alignments));
|
||||
|
||||
// Delimiter row
|
||||
const delimiterParts = widths.map((w, i) => ` ${buildDelimiter(w, table.alignments[i])} `);
|
||||
lines.push(`|${delimiterParts.join('|')}|`);
|
||||
|
||||
// Body rows
|
||||
for (const row of table.body) {
|
||||
lines.push(serializeRow(row.cells, widths, table.alignments));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manipulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Create an empty row with the specified number of columns.
|
||||
const emptyRow = (colCount: number): TableRow => {
|
||||
return { cells: new Array(colCount).fill(null).map(() => ({ content: '' })) };
|
||||
};
|
||||
|
||||
const assertAfterIndex = (afterIndex: number, max: number): void => {
|
||||
if (!Number.isInteger(afterIndex) || afterIndex < -1 || afterIndex > max) {
|
||||
throw new RangeError(`afterIndex must be an integer between -1 and ${max}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Insert a new empty row after the given index.
|
||||
// Use afterIndex = -1 to insert at the beginning of the body.
|
||||
export const addRow = (table: Table, afterIndex: number): Table => {
|
||||
assertAfterIndex(afterIndex, table.body.length - 1);
|
||||
const colCount = table.header.cells.length;
|
||||
const newBody = [...table.body];
|
||||
newBody.splice(afterIndex + 1, 0, emptyRow(colCount));
|
||||
|
||||
return { ...table, body: newBody };
|
||||
};
|
||||
|
||||
// Insert a new empty column after the given index.
|
||||
// Use afterIndex = -1 to insert at the beginning.
|
||||
export const addColumn = (table: Table, afterIndex: number): Table => {
|
||||
assertAfterIndex(afterIndex, table.header.cells.length - 1);
|
||||
const insertAt = afterIndex + 1;
|
||||
const emptyCell = (): TableCell => ({ content: '' });
|
||||
|
||||
const newHeader: TableRow = {
|
||||
cells: [...table.header.cells],
|
||||
};
|
||||
newHeader.cells.splice(insertAt, 0, emptyCell());
|
||||
|
||||
const newAlignments = [...table.alignments];
|
||||
newAlignments.splice(insertAt, 0, ColumnAlignment.None);
|
||||
|
||||
const newBody = table.body.map(row => {
|
||||
const newCells = [...row.cells];
|
||||
newCells.splice(insertAt, 0, emptyCell());
|
||||
return { cells: newCells };
|
||||
});
|
||||
|
||||
return { header: newHeader, alignments: newAlignments, body: newBody };
|
||||
};
|
||||
|
||||
// Delete a row at the given index.
|
||||
// Returns null if the table would become invalid (no body rows is fine,
|
||||
// but we need at least a header).
|
||||
export const deleteRow = (table: Table, rowIndex: number): Table | null => {
|
||||
if (rowIndex < 0 || rowIndex >= table.body.length) return null;
|
||||
|
||||
const newBody = table.body.filter((_, i) => i !== rowIndex);
|
||||
return { ...table, body: newBody };
|
||||
};
|
||||
|
||||
// Delete a column at the given index.
|
||||
// Returns null if the table would have zero columns.
|
||||
export const deleteColumn = (table: Table, colIndex: number): Table | null => {
|
||||
const colCount = table.header.cells.length;
|
||||
if (colCount <= 1) return null;
|
||||
if (colIndex < 0 || colIndex >= colCount) return null;
|
||||
|
||||
const newHeader: TableRow = {
|
||||
cells: table.header.cells.filter((_, i) => i !== colIndex),
|
||||
};
|
||||
|
||||
const newAlignments = table.alignments.filter((_, i) => i !== colIndex);
|
||||
|
||||
const newBody = table.body.map(row => ({
|
||||
cells: row.cells.filter((_, i) => i !== colIndex),
|
||||
}));
|
||||
|
||||
return { header: newHeader, alignments: newAlignments, body: newBody };
|
||||
};
|
||||
|
||||
// Swap two body rows. Row indices are 0-based body indices.
|
||||
export const swapRows = (table: Table, indexA: number, indexB: number): Table | null => {
|
||||
if (indexA < 0 || indexA >= table.body.length) return null;
|
||||
if (indexB < 0 || indexB >= table.body.length) return null;
|
||||
if (indexA === indexB) return table;
|
||||
|
||||
const newBody = [...table.body];
|
||||
[newBody[indexA], newBody[indexB]] = [newBody[indexB], newBody[indexA]];
|
||||
|
||||
return { header: table.header, alignments: table.alignments, body: newBody };
|
||||
};
|
||||
|
||||
// Swap two columns by index.
|
||||
export const swapColumns = (table: Table, indexA: number, indexB: number): Table | null => {
|
||||
const colCount = table.header.cells.length;
|
||||
if (indexA < 0 || indexA >= colCount) return null;
|
||||
if (indexB < 0 || indexB >= colCount) return null;
|
||||
if (indexA === indexB) return table;
|
||||
|
||||
const swapInRow = (cells: TableCell[]): TableCell[] => {
|
||||
const newCells = [...cells];
|
||||
[newCells[indexA], newCells[indexB]] = [newCells[indexB], newCells[indexA]];
|
||||
return newCells;
|
||||
};
|
||||
|
||||
const newHeader: TableRow = { cells: swapInRow(table.header.cells) };
|
||||
const newAlignments = [...table.alignments];
|
||||
[newAlignments[indexA], newAlignments[indexB]] = [newAlignments[indexB], newAlignments[indexA]];
|
||||
const newBody = table.body.map(row => ({ cells: swapInRow(row.cells) }));
|
||||
|
||||
return { header: newHeader, alignments: newAlignments, body: newBody };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Generate a new empty Markdown table with the specified dimensions.
|
||||
export const generateTable = (rows: number, columns: number): string => {
|
||||
if (!Number.isInteger(columns) || columns <= 0) throw new RangeError('columns must be a positive integer');
|
||||
if (!Number.isInteger(rows) || rows < 0) throw new RangeError('rows must be a non-negative integer');
|
||||
|
||||
const header: TableRow = {
|
||||
cells: new Array(columns).fill(null).map(() => ({ content: ' ' })),
|
||||
};
|
||||
const alignments = new Array(columns).fill(ColumnAlignment.None);
|
||||
const body: TableRow[] = [];
|
||||
for (let i = 0; i < rows; i++) {
|
||||
body.push(emptyRow(columns));
|
||||
}
|
||||
|
||||
return serializeTable({ header, alignments, body });
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import toggleCheckbox, { isChecklistLine, reorderChecklistItems, ChecklistItemBlock } from './toggleCheckboxAt';
|
||||
|
||||
describe('toggleCheckboxAt', () => {
|
||||
describe('isChecklistLine', () => {
|
||||
it('identifies standard checklist formats correctly', () => {
|
||||
expect(isChecklistLine('- [ ] Task 1')).toBe(true);
|
||||
expect(isChecklistLine('- [x] Task 2')).toBe(true);
|
||||
expect(isChecklistLine('* [ ] Task 3')).toBe(true);
|
||||
expect(isChecklistLine('+ [ ] Task 4')).toBe(true);
|
||||
expect(isChecklistLine('1. [ ] Task 5')).toBe(true);
|
||||
expect(isChecklistLine(' - [ ] Subtask')).toBe(true);
|
||||
|
||||
expect(isChecklistLine('- Just bullet')).toBe(false);
|
||||
expect(isChecklistLine('# Heading')).toBe(false);
|
||||
expect(isChecklistLine('Plain text')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderChecklistItems', () => {
|
||||
it('moves checked target to the bottom of completed items', () => {
|
||||
const items: ChecklistItemBlock[] = [
|
||||
{ lines: ['- [ ] Task 1'], isChecked: false, isTarget: true },
|
||||
{ lines: ['- [ ] Task 2'], isChecked: false, isTarget: false },
|
||||
{ lines: ['- [x] Task 3'], isChecked: true, isTarget: false },
|
||||
];
|
||||
|
||||
const reordered = reorderChecklistItems(items, true);
|
||||
expect(reordered.map(item => item.lines[0])).toEqual([
|
||||
'- [ ] Task 2',
|
||||
'- [x] Task 3',
|
||||
'- [x] Task 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves unchecked target to the bottom of active items', () => {
|
||||
const items: ChecklistItemBlock[] = [
|
||||
{ lines: ['- [ ] Task 1'], isChecked: false, isTarget: false },
|
||||
{ lines: ['- [x] Task 2'], isChecked: true, isTarget: false },
|
||||
{ lines: ['- [x] Task 3'], isChecked: true, isTarget: true },
|
||||
];
|
||||
|
||||
const reordered = reorderChecklistItems(items, false);
|
||||
expect(reordered.map(item => item.lines[0])).toEqual([
|
||||
'- [ ] Task 1',
|
||||
'- [ ] Task 3',
|
||||
'- [x] Task 2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCheckbox editor command', () => {
|
||||
it('toggles a single item in place without moving', async () => {
|
||||
const initialText = '- [ ] Single item';
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(2), []);
|
||||
|
||||
const handled = toggleCheckbox(2)(editor);
|
||||
expect(handled).toBe(true);
|
||||
expect(editor.state.doc.toString()).toBe('- [x] Single item');
|
||||
|
||||
toggleCheckbox(2)(editor);
|
||||
expect(editor.state.doc.toString()).toBe('- [ ] Single item');
|
||||
});
|
||||
|
||||
it('auto-sorts checked item to the bottom of the list block', async () => {
|
||||
const initialText = [
|
||||
'- [ ] Item 1',
|
||||
'- [ ] Item 2',
|
||||
'- [x] Item 3',
|
||||
'- [ ] Item 4',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(2), []);
|
||||
|
||||
// Click first item (Item 1)
|
||||
toggleCheckbox(2)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'- [ ] Item 2',
|
||||
'- [ ] Item 4',
|
||||
'- [x] Item 3',
|
||||
'- [x] Item 1',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('auto-sorts unchecked item back to the bottom of active items', async () => {
|
||||
const initialText = [
|
||||
'- [ ] Item 1',
|
||||
'- [x] Item 2',
|
||||
'- [x] Item 3',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(initialText.indexOf('Item 2')), []);
|
||||
|
||||
// Uncheck Item 2
|
||||
toggleCheckbox(initialText.indexOf('Item 2'))(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'- [ ] Item 1',
|
||||
'- [ ] Item 2',
|
||||
'- [x] Item 3',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('preserves attached child subtasks when moving a parent item', async () => {
|
||||
const initialText = [
|
||||
'- [ ] Parent 1',
|
||||
' - sub-detail line 1',
|
||||
' - sub-detail line 2',
|
||||
'- [ ] Parent 2',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(2), []);
|
||||
|
||||
// Check Parent 1
|
||||
toggleCheckbox(2)(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'- [ ] Parent 2',
|
||||
'- [x] Parent 1',
|
||||
' - sub-detail line 1',
|
||||
' - sub-detail line 2',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('respects autoSort: false option to toggle strictly in place', async () => {
|
||||
const initialText = [
|
||||
'- [ ] Item 1',
|
||||
'- [ ] Item 2',
|
||||
].join('\n');
|
||||
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(2), []);
|
||||
|
||||
toggleCheckbox(2, { autoSort: false })(editor);
|
||||
|
||||
expect(editor.state.doc.toString()).toBe([
|
||||
'- [x] Item 1',
|
||||
'- [ ] Item 2',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('returns false for lines without any checkbox', async () => {
|
||||
const initialText = 'Just a regular markdown line';
|
||||
const editor = await createTestEditor(initialText, EditorSelection.cursor(5), []);
|
||||
|
||||
const result = toggleCheckbox(5)(editor);
|
||||
expect(result).toBe(false);
|
||||
expect(editor.state.doc.toString()).toBe(initialText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Command, EditorView } from '@codemirror/view';
|
||||
|
||||
export interface ToggleCheckboxOptions {
|
||||
autoSort?: boolean;
|
||||
}
|
||||
|
||||
export interface ChecklistItemBlock {
|
||||
lines: string[];
|
||||
isChecked: boolean;
|
||||
isTarget: boolean;
|
||||
}
|
||||
|
||||
const CHECKLIST_REGEX = /^(\s*(?:[-*+]|\d+\.)\s+)\[([ xX])\]/;
|
||||
|
||||
/**
|
||||
* 判断单行文本是否符合 Markdown 任务列表语法(如 `- [ ] 待办`、`1. [x] 已完成`)
|
||||
*/
|
||||
export const isChecklistLine = (lineText: string): boolean => {
|
||||
return CHECKLIST_REGEX.test(lineText);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单行前置缩进空格数
|
||||
*/
|
||||
export const getLineIndent = (lineText: string): number => {
|
||||
const match = lineText.match(/^\s*/);
|
||||
return match ? match[0].length : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 对连续待办列表项执行重排(对标 Apple 备忘录自动沉底体验)
|
||||
* - 未勾选项保留在顶部
|
||||
* - 新勾选的项沉底至已完成列表末尾
|
||||
* - 取消勾选的项浮动至未完成列表末尾
|
||||
*/
|
||||
export const reorderChecklistItems = (
|
||||
items: ChecklistItemBlock[],
|
||||
targetChecked: boolean,
|
||||
): ChecklistItemBlock[] => {
|
||||
const unchecked: ChecklistItemBlock[] = [];
|
||||
const checked: ChecklistItemBlock[] = [];
|
||||
let targetItem: ChecklistItemBlock | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
const isItemChecked = item.isTarget ? targetChecked : item.isChecked;
|
||||
const updatedItem: ChecklistItemBlock = {
|
||||
...item,
|
||||
isChecked: isItemChecked,
|
||||
lines: item.lines.map((l, idx) => {
|
||||
if (item.isTarget && idx === 0) {
|
||||
return l.replace(CHECKLIST_REGEX, (_match, prefix) => `${prefix}[${targetChecked ? 'x' : ' '}]`);
|
||||
}
|
||||
return l;
|
||||
}),
|
||||
};
|
||||
|
||||
if (item.isTarget) {
|
||||
targetItem = updatedItem;
|
||||
} else if (isItemChecked) {
|
||||
checked.push(updatedItem);
|
||||
} else {
|
||||
unchecked.push(updatedItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem) {
|
||||
if (targetChecked) {
|
||||
// 勾选完成:沉底至已完成项的最末端
|
||||
checked.push(targetItem);
|
||||
} else {
|
||||
// 取消勾选:浮动至未完成项的最末端
|
||||
unchecked.push(targetItem);
|
||||
}
|
||||
}
|
||||
|
||||
return [...unchecked, ...checked];
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换指定位置的复选框状态,并支持连续待办清单自动沉底
|
||||
*/
|
||||
const toggleCheckbox = (linePos: number, options: ToggleCheckboxOptions = { autoSort: true }): Command => (target: EditorView) => {
|
||||
const state = target.state;
|
||||
if (linePos >= state.doc.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const line = state.doc.lineAt(linePos);
|
||||
const checklistMatch = line.text.match(CHECKLIST_REGEX);
|
||||
|
||||
// 非行首标准列表项,回退到就地匹配替换
|
||||
if (!checklistMatch) {
|
||||
const genericMatch = line.text.match(/\[([ xX])\]/);
|
||||
if (!genericMatch) {
|
||||
return false;
|
||||
}
|
||||
const isChecked = genericMatch[1].toLowerCase() === 'x';
|
||||
const checkboxPos = genericMatch.index! + line.from;
|
||||
target.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const isCurrentlyChecked = checklistMatch[2].toLowerCase() === 'x';
|
||||
const newChecked = !isCurrentlyChecked;
|
||||
const shouldAutoSort = options.autoSort !== false;
|
||||
|
||||
if (!shouldAutoSort) {
|
||||
const checkboxPos = checklistMatch[1].length + line.from;
|
||||
target.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: newChecked ? '[x]' : '[ ]' }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 自动排序模式:查找同缩进层级的连续清单块
|
||||
const baseIndent = getLineIndent(line.text);
|
||||
let startLineNo = line.number;
|
||||
let endLineNo = line.number;
|
||||
|
||||
// 向上探测列表起始行
|
||||
for (let i = line.number - 1; i >= 1; i--) {
|
||||
const prevLine = state.doc.line(i);
|
||||
if (!prevLine.text.trim()) break; // 空行阻断清单块
|
||||
const indent = getLineIndent(prevLine.text);
|
||||
if (isChecklistLine(prevLine.text) && indent === baseIndent) {
|
||||
startLineNo = i;
|
||||
} else if (indent > baseIndent) {
|
||||
continue;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 向下探测列表结束行
|
||||
for (let i = line.number + 1; i <= state.doc.lines; i++) {
|
||||
const nextLine = state.doc.line(i);
|
||||
if (!nextLine.text.trim()) break; // 空行阻断清单块
|
||||
const indent = getLineIndent(nextLine.text);
|
||||
if (isChecklistLine(nextLine.text) && indent === baseIndent) {
|
||||
endLineNo = i;
|
||||
} else if (indent > baseIndent) {
|
||||
endLineNo = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 将行聚合成项(包含可能的子缩进行)
|
||||
const items: ChecklistItemBlock[] = [];
|
||||
let currentItem: ChecklistItemBlock | null = null;
|
||||
|
||||
for (let i = startLineNo; i <= endLineNo; i++) {
|
||||
const currentDocLine = state.doc.line(i);
|
||||
const indent = getLineIndent(currentDocLine.text);
|
||||
const isItemHeader = isChecklistLine(currentDocLine.text) && indent === baseIndent;
|
||||
|
||||
if (isItemHeader) {
|
||||
if (currentItem) {
|
||||
items.push(currentItem);
|
||||
}
|
||||
const match = currentDocLine.text.match(CHECKLIST_REGEX);
|
||||
currentItem = {
|
||||
lines: [currentDocLine.text],
|
||||
isChecked: match ? match[2].toLowerCase() === 'x' : false,
|
||||
isTarget: i === line.number,
|
||||
};
|
||||
} else if (currentItem) {
|
||||
currentItem.lines.push(currentDocLine.text);
|
||||
}
|
||||
}
|
||||
if (currentItem) {
|
||||
items.push(currentItem);
|
||||
}
|
||||
|
||||
// 单项列表直接就地切换,不触发重排
|
||||
if (items.length <= 1) {
|
||||
const checkboxPos = checklistMatch[1].length + line.from;
|
||||
target.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: newChecked ? '[x]' : '[ ]' }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const reordered = reorderChecklistItems(items, newChecked);
|
||||
const newContentLines: string[] = [];
|
||||
for (const item of reordered) {
|
||||
newContentLines.push(...item.lines);
|
||||
}
|
||||
|
||||
const replaceFrom = state.doc.line(startLineNo).from;
|
||||
const replaceTo = state.doc.line(endLineNo).to;
|
||||
const newText = newContentLines.join('\n');
|
||||
|
||||
target.dispatch({
|
||||
changes: [{ from: replaceFrom, to: replaceTo, insert: newText }],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export default toggleCheckbox;
|
||||
在新工单中引用
屏蔽一个用户