From b6acbf6f3046b4fb11313658235cb3b6fc625d88 Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 12:17:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(image):=20=E7=BC=96=E8=BE=91=E5=8C=BA?= =?UTF-8?q?=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=E5=9B=BE=E7=89=87=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=A7=A3=E6=9E=90=EF=BC=88docs/16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 粘贴/插入的图片落盘与相对引用本身没问题,但编辑区从未解析相对路径: 页面在应用源下按相对路径取图必 404、只剩裂图。新增 image NodeView, 仅显示层把本地图片按文档目录归一化后经 read_file_base64 读成 data URL; 文档内容不变,Web 模式不启用。vitest 83 通过 + 2 跳过。 --- docs/16-image-display-fix.md | 29 ++++++++++++++ src/App.tsx | 28 ++++++++++++++ src/components/EditorPane.tsx | 4 +- src/editor/editor.ts | 5 ++- src/editor/plugins.ts | 45 ++++++++++++++++++++++ tests/image-display.test.ts | 72 +++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 docs/16-image-display-fix.md create mode 100644 tests/image-display.test.ts diff --git a/docs/16-image-display-fix.md b/docs/16-image-display-fix.md new file mode 100644 index 0000000..ff55792 --- /dev/null +++ b/docs/16-image-display-fix.md @@ -0,0 +1,29 @@ +# 图片粘贴不显示修复(2026-11-14,验收新问题) + +## 现象 + +在已保存文档里粘贴/插入图片,编辑区只有一个裂图占位(截图即如此),图片不显示。 + +## 根因(显示侧缺失,与粘贴本身无关) + +粘贴/插入流程本身是通的:图片落盘到 `<文档名>.assets/`、文档里写入相对路径(如 `alpha.assets/image-*.png`)。但**编辑区的显示从来没有做过相对路径解析**:编辑器页面运行在应用源(tauri.localhost)下,浏览器把相对路径按应用源去找图,必然 404 → 裂图。之前各阶段的验证都在 Web 模式(图片是 data URL、能直接显示),Tauri 下相对路径图片的显示一直是空白区,验收才撞上。 + +## 修法 + +只动显示层,文档内容与落盘方式不变: + +- `src/editor/plugins.ts` 新增图片 NodeView 插件:image 节点渲染时,本地图片(相对/绝对/file:// 路径)先放透明占位,再异步换成解析出的 data URL;http/data/blob 地址直通。 +- 解析由 App 注入(`resolveImageDisplay`):按当前文档目录归一化路径(复用导出管线同款的盘符/.. 处理),经既有 Rust 命令 `read_file_base64` 读文件转 data URL。Web 模式不启用该插件,保持原行为。 +- 读不到(文件缺失)时保留占位与 alt,不崩溃、文档不变。 + +## 验证 + +- 新增 `tests/image-display.test.ts` 3 例:相对路径图片 DOM 中被换成 data URL 且序列化仍是相对路径;粘贴图片(落盘相对路径)同样能显示;文件缺失不崩溃。 +- 全量:tsc 0 错误、vitest **83 通过 + 2 跳过**、`npm run build` 通过。 +- jsdom 排查注记:upload 插件以 `instanceof ClipboardEvent/DragEvent` 把关事件,jsdom 缺这两个全局会导致粘贴链在测试里假死,测试文件已垫回。 + +## 复验建议(Windows) + +- 在已保存文档里 Ctrl+V 粘贴一张截图:应立即显示图片本体,文档里仍是相对路径引用。 +- 拖入一张图片文件、菜单插入一张图片:同样应显示。 +- 把 .assets 里某张图改名后重开文档:该图位置为空占位(alt 文本在),其余内容不受影响。 diff --git a/src/App.tsx b/src/App.tsx index f85500d..729f787 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -807,6 +807,33 @@ export default function App() { } }, []) + /** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */ + const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise => { + try { + if (/^(https?:|data:|blob:)/i.test(src)) return null + let decoded = src + if (/^file:\/\//i.test(decoded)) decoded = decoded.replace(/^file:\/\//i, '').replace(/^\/(?=[a-zA-Z]:)/, '') + try { decoded = decodeURIComponent(decoded) } catch { /* 保持原样 */ } + const isAbsolute = /^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/') || decoded.startsWith('\\\\') + const raw = isAbsolute ? decoded : `${dirOf(docPath)}/${decoded}` + const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? '' + const segments: string[] = [] + for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) { + if (part === '..') segments.pop() + else if (part !== '.' && part !== '') segments.push(part) + } + const joined = drive + '/' + segments.join('/') + const cleanSrc = src.split(/[?#]/)[0] + const ext = (cleanSrc.split('.').pop() ?? '').toLowerCase() + const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' + : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream' + const base64 = await readFileBase64(joined) + return `data:${mime};base64,${base64}` + } catch { + return null + } + }, []) + const buildExportHtml = useCallback(async ( target: DocTab, imageMode: ImageMode, @@ -1140,6 +1167,7 @@ export default function App() { typewriter={ui.typewriter} onImageFile={handleImageFile} onImageError={(message) => setError(`图片插入失败:${message}`)} + resolveImageDisplay={tauriMode && tab.path ? (src) => resolveDisplayImage(tab.path as string, src) : undefined} onReady={(api) => { paneFindRef.current = api.editor ? api : null editorRef.current = api.editor diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index cf32536..7f2fc20 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -32,6 +32,7 @@ interface Props { onReady: (api: EditorPaneApi) => void onImageFile?: (file: File) => Promise onImageError?: (message: string) => void + resolveImageDisplay?: (src: string) => Promise focusMode: boolean typewriter: boolean } @@ -49,7 +50,7 @@ const EMPTY_API: EditorPaneApi = { } /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ -export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) { +export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError, resolveImageDisplay, focusMode, typewriter }: Props) { const wrapRef = useRef(null) const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({ matches: [], current: -1, query: '', opts: null, @@ -111,6 +112,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal }, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)), onImageError: (message) => callbacksRef.current.onImageError?.(message), + resolveImageDisplay: resolveImageDisplay ? (src) => resolveImageDisplay(src) : undefined, }) editorObjRef.current = editor const jumpToHeading = (text: string) => { diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 7585e00..b6f29be 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard' import { indent } from '@milkdown/kit/plugin/indent' import { math } from '@milkdown/plugin-math' import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' -import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, selectionInTable } from './plugins' +import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, selectionInTable } from './plugins' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews' export interface CursorInfo { line: number; col: number } @@ -28,6 +28,8 @@ export interface EditorOptions { /** 粘贴/拖入图片文件的落盘处理:返回应写入文档的图片地址(相对路径或 data URL)。缺省用会话级对象 URL。 */ onImageFile?: (file: File) => Promise onImageError?: (message: string) => void + /** 本地图片的显示解析:把相对/本地路径读成 data URL 供编辑区显示(仅显示层,文档内容不改)。 */ + resolveImageDisplay?: (src: string) => Promise } export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, callbacks: EditorCallbacks = {}, options: EditorOptions = {}) { @@ -93,6 +95,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, .use(math) .use(upload) .use(createPlainTextPastePlugin()) + if (options.resolveImageDisplay) editor.use(createImageDisplayPlugin(options.resolveImageDisplay)) return editor } diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts index 1c4ee92..d9afc41 100644 --- a/src/editor/plugins.ts +++ b/src/editor/plugins.ts @@ -390,3 +390,48 @@ export function createPlainTextPastePlugin() { }, })) } + +// ————————————————— 图片显示解析(验收新问题:粘贴图片不显示) ————————————————— +// 文档里的图片是相对路径(如 alpha.assets/x.png),但编辑器页面运行在应用源下, +// 浏览器拿相对路径去应用源找图必然 404——粘贴/插入的图片在编辑区全是裂图。 +// 这里给 image 节点挂 NodeView:仅显示层把本地图片读成 data URL(经 App 传入的 +// resolveImageDisplay,内部走 read_file_base64),文档里的相对路径本身不改。 +const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' + +export function createImageDisplayPlugin(resolve: (src: string) => Promise) { + return $prose(() => new Plugin({ + props: { + nodeViews: { + image: (node) => { + const img = document.createElement('img') + img.alt = (node.attrs.alt as string) ?? '' + if (node.attrs.title) img.title = node.attrs.title as string + let currentSrc = '' + const apply = (src: string) => { + currentSrc = src + if (/^(https?:|data:|blob:)/i.test(src)) { + img.src = src + return + } + img.src = TRANSPARENT_PIXEL + void resolve(src) + .then((dataUrl) => { if (dataUrl && currentSrc === src) img.src = dataUrl }) + .catch(() => undefined) + } + apply((node.attrs.src as string) ?? '') + return { + dom: img, + update: (next) => { + if (next.type.name !== 'image') return false + img.alt = (next.attrs.alt as string) ?? '' + const src = (next.attrs.src as string) ?? '' + if (src !== currentSrc) apply(src) + return true + }, + ignoreMutation: () => true, + } + }, + }, + }, + })) +} diff --git a/tests/image-display.test.ts b/tests/image-display.test.ts new file mode 100644 index 0000000..c445fc1 --- /dev/null +++ b/tests/image-display.test.ts @@ -0,0 +1,72 @@ +// 图片显示解析回归:文档里的相对路径图片在编辑区经 NodeView 读成 data URL 显示; +// 文档内容(序列化)保持相对路径不变。upload 插件以 instanceof 把关事件类型, +// jsdom 缺 ClipboardEvent/DragEvent 全局,这里垫上。 +import { describe, it, expect } from 'vitest' +import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' +import { editorViewCtx } from '@milkdown/kit/core' + +class FakeClipboardEvent extends Event { + clipboardData: unknown + constructor(type: string, init?: { clipboardData?: unknown }) { + super(type, { bubbles: true, cancelable: true }) + this.clipboardData = init?.clipboardData + } +} +;(globalThis as Record).ClipboardEvent = FakeClipboardEvent +;(globalThis as Record).DragEvent = class extends Event {} + +const tick = (ms = 40) => new Promise((r) => setTimeout(r, ms)) + +describe('图片显示解析', () => { + it('相对路径图片在 DOM 里被换成 data URL,文档仍是相对路径', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = createMarkdownEditor(host, '看图 ![截图](doc.assets/a.png) 完', {}, { + resolveImageDisplay: async (src) => (src === 'doc.assets/a.png' ? 'data:image/png;base64,QUJD' : null), + }) + await editor.create() + await tick() + const img = host.querySelector('img:not(.ProseMirror-separator)') + expect(img?.getAttribute('src')).toBe('data:image/png;base64,QUJD') + expect(img?.getAttribute('alt')).toBe('截图') + expect(getMarkdown(editor)).toContain('![截图](doc.assets/a.png)') + await editor.destroy() + host.remove() + }) + + it('粘贴图片落盘为相对路径后同样能显示', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = createMarkdownEditor(host, '正文', {}, { + onImageFile: async () => 'doc.assets/pasted.png', + resolveImageDisplay: async (src) => (src === 'doc.assets/pasted.png' ? 'data:image/png;base64,QUJD' : null), + }) + await editor.create() + const view = editor.action((ctx) => ctx.get(editorViewCtx)) + const file = new File(['png'], 'image.png', { type: 'image/png' }) + const event = new FakeClipboardEvent('paste', { + clipboardData: { files: [file], items: [], getData: () => '' }, + }) + view.dom.dispatchEvent(event) + await tick(80) + expect(getMarkdown(editor)).toContain('doc.assets/pasted.png') + const img = host.querySelector('img:not(.ProseMirror-separator)') + expect(img?.getAttribute('src')).toBe('data:image/png;base64,QUJD') + await editor.destroy() + host.remove() + }) + + it('解析不到(文件缺失)时不崩溃、文档不变', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = createMarkdownEditor(host, '![x](missing.png)', {}, { + resolveImageDisplay: async () => null, + }) + await editor.create() + await tick() + expect(host.querySelector('img')).not.toBeNull() + expect(getMarkdown(editor)).toContain('![x](missing.png)') + await editor.destroy() + host.remove() + }) +})