feat: 添加 Markdown 编辑器表格自动格式化插件

This commit is contained in:
seahi committed 2026-09-23 09:57:30 +08:00
1 parent 1282172c51
commit 41d67aebff
1 file changed
+148
+148
View File
@@ -0,0 +1,148 @@
import { ViewPlugin, ViewUpdate, EditorView } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
// Helper to count CJK chars as 2 for better alignment
function getDisplayLength(str: string): number {
let len = 0;
for (let i = 0; i < str.length; i++) {
len += str.charCodeAt(i) > 255 ? 2 : 1;
}
return len;
}
function padEnd(str: string, targetLength: number): string {
const curLen = getDisplayLength(str);
if (curLen >= targetLength) return str;
return str + ' '.repeat(targetLength - curLen);
}
export function formatMarkdownTable(tableStr: string): string {
const lines = tableStr.trim().split('\n');
if (lines.length < 2) return tableStr; // Not a valid table
const rows = lines.map((line) => {
const content = line.trim();
const cols = content.split('|');
// Remove empty outer columns if they exist
if (cols.length > 0 && cols[0].trim() === '') cols.shift();
if (cols.length > 0 && cols[cols.length - 1].trim() === '') cols.pop();
return cols.map((c) => c.trim());
});
const colWidths: number[] = [];
rows.forEach((row) => {
row.forEach((col, i) => {
const len = getDisplayLength(col);
if (!colWidths[i] || len > colWidths[i]) {
colWidths[i] = len;
}
});
});
// Make sure second row (separator) has minimum width of 3 (---)
colWidths.forEach((w, i) => {
if (w < 3) colWidths[i] = 3;
});
const formattedLines = rows.map((row, rIndex) => {
if (rIndex === 1) {
// separator row
const parts = colWidths.map((w, i) => {
const col = row[i] || '';
const leftAlign = col.startsWith(':');
const rightAlign = col.endsWith(':');
let sep = '-'.repeat(w);
if (leftAlign && rightAlign) {
sep = ':' + '-'.repeat(w - 2) + ':';
} else if (leftAlign) {
sep = ':' + '-'.repeat(w - 1);
} else if (rightAlign) {
sep = '-'.repeat(w - 1) + ':';
}
return sep;
});
return '| ' + parts.join(' | ') + ' |';
} else {
const parts = colWidths.map((w, i) => {
const col = row[i] || '';
return padEnd(col, w);
});
return '| ' + parts.join(' | ') + ' |';
}
});
return formattedLines.join('\n');
}
export const tableFormatterPlugin = ViewPlugin.fromClass(
class {
lastTableRange: any = null;
constructor(view: EditorView) {
this.lastTableRange = this.getCurrentTableRange(view);
}
update(update: ViewUpdate) {
if (update.selectionSet || update.docChanged || update.focusChanged) {
const view = update.view;
const currentTableRange: { from: number; to: number } | null =
this.getCurrentTableRange(view);
const focusLost = !view.hasFocus;
const movedOutOfTable =
this.lastTableRange &&
(!currentTableRange || currentTableRange.from !== this.lastTableRange.from);
// Trigger format if focus is lost or moved out of the table
if (this.lastTableRange && (focusLost || movedOutOfTable)) {
this.formatTable(view, this.lastTableRange);
}
if (!focusLost) {
this.lastTableRange = currentTableRange;
} else {
this.lastTableRange = null;
}
}
}
getCurrentTableRange(view: EditorView) {
const state = view.state;
const pos = state.selection.main.head;
const tree = syntaxTree(state);
let tableRange: any = null;
tree.iterate({
from: pos,
to: pos,
enter: (node: any) => {
if (node.name === 'Table') {
tableRange = { from: node.from, to: node.to };
return false; // stop iteration
}
}
});
return tableRange;
}
formatTable(view: EditorView, range: { from: number; to: number }) {
const state = view.state;
// check if valid range
if (range.to > state.doc.length) return;
const tableStr = state.doc.sliceString(range.from, range.to);
// sanity check
if (!tableStr.trim().startsWith('|')) return;
const formatted = formatMarkdownTable(tableStr);
if (formatted !== tableStr) {
// We schedule the dispatch in the next microtask to avoid updating during update
Promise.resolve().then(() => {
view.dispatch({
changes: { from: range.from, to: range.to, insert: formatted }
});
});
}
}
}
);