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