From 1282172c518aeda1beda624b83504ba3244d81bb Mon Sep 17 00:00:00 2001 From: seaHi Date: Wed, 23 Sep 2026 09:57:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Markdown=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/editor/imagePreviewPlugin.ts | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/lib/editor/imagePreviewPlugin.ts diff --git a/src/lib/editor/imagePreviewPlugin.ts b/src/lib/editor/imagePreviewPlugin.ts new file mode 100644 index 0000000..159599e --- /dev/null +++ b/src/lib/editor/imagePreviewPlugin.ts @@ -0,0 +1,91 @@ +import { WidgetType, EditorView, ViewPlugin, Decoration, ViewUpdate } from '@codemirror/view'; +import type { DecorationSet } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { Range } from '@codemirror/state'; + +class ImagePreviewWidget extends WidgetType { + constructor(readonly url: string) { + super(); + } + + eq(other: ImagePreviewWidget) { + return this.url === other.url; + } + + toDOM() { + const wrap = document.createElement('div'); + wrap.className = 'cm-image-preview-wrapper'; + wrap.style.userSelect = 'none'; + wrap.contentEditable = 'false'; + + const img = document.createElement('img'); + img.src = this.url; + img.className = 'cm-image-preview'; + img.style.maxWidth = '100%'; + img.style.maxHeight = '400px'; + img.style.objectFit = 'contain'; + img.style.display = 'block'; + img.style.marginTop = '8px'; + img.style.marginBottom = '8px'; + img.style.borderRadius = '4px'; + img.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; + + wrap.appendChild(img); + return wrap; + } +} + +function getImageDecorations(view: EditorView) { + const widgets: Range[] = []; + const { state } = view; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(state).iterate({ + from, + to, + enter: (node: any) => { + if (node.name === 'Image') { + // 查找图片语法中的 URL 节点 + const urlNode = node.node.getChild('URL'); + if (urlNode) { + let url = state.doc.sliceString(urlNode.from, urlNode.to); + // 处理可能的带尖括号的 URL + if (url.startsWith('<') && url.endsWith('>')) { + url = url.slice(1, -1); + } + + const deco = Decoration.widget({ + widget: new ImagePreviewWidget(url), + block: true, // 作为块级元素显示在下方 + side: 1 // 插入在节点之后 + }); + + widgets.push(deco.range(node.to)); + } + } + } + }); + } + + return Decoration.set(widgets.sort((a, b) => a.from - b.from)); +} + +export const imagePreviewPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = getImageDecorations(view); + } + + update(update: ViewUpdate) { + // 文档改变或视口改变时重新计算 + if (update.docChanged || update.viewportChanged) { + this.decorations = getImageDecorations(update.view); + } + } + }, + { + decorations: (v) => v.decorations + } +);