fix(image): 编辑区相对路径图片显示解析(docs/16)
粘贴/插入的图片落盘与相对引用本身没问题,但编辑区从未解析相对路径: 页面在应用源下按相对路径取图必 404、只剩裂图。新增 image NodeView, 仅显示层把本地图片按文档目录归一化后经 read_file_base64 读成 data URL; 文档内容不变,Web 模式不启用。vitest 83 通过 + 2 跳过。
This commit is contained in:
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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user