diff --git a/src/lib/editor/tableFormatterPlugin.ts b/src/lib/editor/tableFormatterPlugin.ts new file mode 100644 index 0000000..bf4eeb0 --- /dev/null +++ b/src/lib/editor/tableFormatterPlugin.ts @@ -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 } + }); + }); + } + } + } +);