From 71c59837ebcbd48bd32c852b9410034a3e89c25a Mon Sep 17 00:00:00 2001 From: seaHi Date: Wed, 23 Sep 2026 10:51:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=20Markdown=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=B7=BB=E5=8A=A0=E8=A1=A8=E6=A0=BC=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/MarkdownEditor.svelte | 3 +- src/lib/editor/tableFormatterPlugin.ts | 133 +++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/MarkdownEditor.svelte b/src/lib/components/admin/MarkdownEditor.svelte index 25e525e..309eb78 100644 --- a/src/lib/components/admin/MarkdownEditor.svelte +++ b/src/lib/components/admin/MarkdownEditor.svelte @@ -21,7 +21,7 @@ } from 'lucide-svelte'; import { addToast } from '$lib/stores/toast.svelte'; import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin'; - import { tableFormatterPlugin } from '$lib/editor/tableFormatterPlugin'; + import { tableFormatterPlugin, tablePreviewField } from '$lib/editor/tableFormatterPlugin'; let { value = $bindable(''), @@ -123,6 +123,7 @@ }), imagePreviewPlugin, tableFormatterPlugin, + tablePreviewField, customTheme, EditorView.lineWrapping, EditorView.updateListener.of((update) => { diff --git a/src/lib/editor/tableFormatterPlugin.ts b/src/lib/editor/tableFormatterPlugin.ts index 190ec95..0a81541 100644 --- a/src/lib/editor/tableFormatterPlugin.ts +++ b/src/lib/editor/tableFormatterPlugin.ts @@ -1,4 +1,6 @@ -import { ViewPlugin, ViewUpdate, EditorView } from '@codemirror/view'; +import { ViewPlugin, ViewUpdate, EditorView, WidgetType, Decoration } from '@codemirror/view'; +import type { DecorationSet } from '@codemirror/view'; +import { StateField, type Transaction, type EditorState, Range } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; // Helper to count CJK chars as 2 for better alignment @@ -164,3 +166,132 @@ export const tableFormatterPlugin = ViewPlugin.fromClass( } } ); + +class TablePreviewWidget extends WidgetType { + constructor(readonly tableStr: string) { + super(); + } + + eq(other: TablePreviewWidget) { + return this.tableStr === other.tableStr; + } + + toDOM() { + const lines = this.tableStr.trim().split('\n'); + const rows = lines.map((line) => { + const content = line.trim(); + const cols = content.split('|'); + 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 container = document.createElement('div'); + container.className = 'cm-table-preview-wrapper'; + container.style.userSelect = 'none'; + container.contentEditable = 'false'; + container.style.overflowX = 'auto'; + container.style.margin = '1.5rem 0'; + container.style.cursor = 'pointer'; + container.title = '点击编辑表格 (Click to edit table)'; + + if (rows.length === 0) return container; + + const table = document.createElement('table'); + table.style.width = '100%'; + table.style.borderCollapse = 'collapse'; + + const alignments: string[] = []; + if (rows.length > 1) { + rows[1].forEach((col) => { + const left = col.startsWith(':'); + const right = col.endsWith(':'); + if (left && right) alignments.push('center'); + else if (right) alignments.push('right'); + else alignments.push('left'); + }); + } + + rows.forEach((row, rowIndex) => { + if (rowIndex === 1) return; // Skip separator + + const tr = document.createElement('tr'); + const isHeader = rowIndex === 0; + + row.forEach((col, colIndex) => { + const td = document.createElement(isHeader ? 'th' : 'td'); + // Basic markdown parsing for links and strong could be done here, but textContent is safer + td.textContent = col; + td.style.border = '1px solid var(--border-color, #e5e7eb)'; + td.style.padding = '0.6rem 1rem'; + td.style.textAlign = alignments[colIndex] || 'left'; + if (isHeader) { + td.style.backgroundColor = 'rgba(0, 0, 0, 0.03)'; + td.style.fontWeight = '600'; + } + tr.appendChild(td); + }); + table.appendChild(tr); + }); + + container.appendChild(table); + return container; + } +} + +function getTableDecorations(state: EditorState) { + const widgets: Range[] = []; + const pos = state.selection.main.head; + let currentTableRange: { from: number; to: number } | null = null; + + // First find if the cursor is currently inside ANY table + syntaxTree(state).iterate({ + from: pos, + to: pos, + enter: (node: { name: string; from: number; to: number }) => { + if (node.name === 'Table') { + currentTableRange = { from: node.from, to: node.to }; + return false; + } + } + }); + + // Then find all tables and replace those that the cursor is NOT inside + syntaxTree(state).iterate({ + enter: (node: { name: string; from: number; to: number }) => { + if (node.name === 'Table') { + const isCurrent = + currentTableRange && + node.from === currentTableRange.from && + node.to === currentTableRange.to; + + if (!isCurrent) { + const tableStr = state.doc.sliceString(node.from, node.to); + // Skip malformed tables or empty ones + if (tableStr.trim().length > 0) { + const deco = Decoration.replace({ + widget: new TablePreviewWidget(tableStr), + block: true + }); + widgets.push(deco.range(node.from, node.to)); + } + } + } + } + }); + + return Decoration.set(widgets.sort((a, b) => a.from - b.from)); +} + +export const tablePreviewField = StateField.define({ + create(state) { + return getTableDecorations(state); + }, + update(value, tr: Transaction) { + if (tr.docChanged || tr.selection) { + return getTableDecorations(tr.state); + } + return value; + }, + provide: (f) => EditorView.decorations.from(f) +});