fix(image): 编辑区相对路径图片显示解析(docs/16)

粘贴/插入的图片落盘与相对引用本身没问题,但编辑区从未解析相对路径:
页面在应用源下按相对路径取图必 404、只剩裂图。新增 image NodeView,
仅显示层把本地图片按文档目录归一化后经 read_file_base64 读成 data URL;
文档内容不变,Web 模式不启用。vitest 83 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-14 12:17:37 +08:00
parent 2ddd12082e
commit b6acbf6f30
6 changed files with 181 additions and 2 deletions

View File

@@ -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 文本在),其余内容不受影响。

View File

@@ -807,6 +807,33 @@ export default function App() {
}
}, [])
/** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */
const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise<string | null> => {
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

View File

@@ -32,6 +32,7 @@ interface Props {
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
onImageError?: (message: string) => void
resolveImageDisplay?: (src: string) => Promise<string | null>
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<HTMLDivElement>(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) => {

View File

@@ -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<string>
onImageError?: (message: string) => void
/** 本地图片的显示解析:把相对/本地路径读成 data URL 供编辑区显示(仅显示层,文档内容不改)。 */
resolveImageDisplay?: (src: string) => Promise<string | null>
}
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
}

View File

@@ -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<string | null>) {
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,
}
},
},
},
}))
}

View File

@@ -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<string, unknown>).ClipboardEvent = FakeClipboardEvent
;(globalThis as Record<string, unknown>).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()
})
})