feat: 添加 Markdown 编辑器表格自动格式化插件
This commit is contained in:
1 parent
1282172c51
commit
41d67aebff
1 file changed
+148
@@ -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 }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in new issue
Block a user