fix(image): 编辑区相对路径图片显示解析(docs/16)
粘贴/插入的图片落盘与相对引用本身没问题,但编辑区从未解析相对路径: 页面在应用源下按相对路径取图必 404、只剩裂图。新增 image NodeView, 仅显示层把本地图片按文档目录归一化后经 read_file_base64 读成 data URL; 文档内容不变,Web 模式不启用。vitest 83 通过 + 2 跳过。
This commit is contained in:
29
docs/16-image-display-fix.md
Normal file
29
docs/16-image-display-fix.md
Normal 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 文本在),其余内容不受影响。
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
72
tests/image-display.test.ts
Normal file
72
tests/image-display.test.ts
Normal 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, '看图  完', {}, {
|
||||
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('')
|
||||
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, '', {}, {
|
||||
resolveImageDisplay: async () => null,
|
||||
})
|
||||
await editor.create()
|
||||
await tick()
|
||||
expect(host.querySelector('img')).not.toBeNull()
|
||||
expect(getMarkdown(editor)).toContain('')
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user