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

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