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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -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',
' ![](./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;
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;