fix(acceptance-r2): 二轮验收 4 项回修(docs/15)

C5 粘贴:handleDOMEvents.paste 前置拦截无 HTML/无文件的多行纯文本按段落
插入(真机菜单粘贴走 execCommand 兜底、与 Ctrl+V 同中 clipboard 插件的
Markdown 解析);插入改开放 Slice,消除段中空段。
E3 相对导出:先选保存位置,再按导出目录重写图片相对路径。
E4 复制为 HTML:剪贴板变体公式只留 MathML、Mermaid 栅格化 PNG。
E6 HTML 导入:DOM 预处理剥 head/脚本/SVG、自家 KaTeX 还原 $ 源码、
GFM 插件加载失败降级。vitest 79 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-14 11:17:12 +08:00
parent 9bd3f3de97
commit b2d36b7397
8 changed files with 348 additions and 34 deletions

View File

@@ -807,12 +807,20 @@ export default function App() {
}
}, [])
const buildExportHtml = useCallback(async (target: DocTab, imageMode: ImageMode): Promise<string> => {
const buildExportHtml = useCallback(async (
target: DocTab,
imageMode: ImageMode,
extra?: { exportDir?: string; forClipboard?: boolean },
): Promise<string> => {
return markdownToExportHtml(target.content, {
title: target.title,
themeId: docOverrides[target.id] ?? ui.docTheme,
imageMode,
resolveImage: tauriMode ? resolveExportImage(target.path) : undefined,
docDir: target.path ? dirOf(target.path) : undefined,
exportDir: extra?.exportDir,
forClipboard: extra?.forClipboard,
mermaidPng: extra?.forClipboard,
})
}, [docOverrides, ui.docTheme, tauriMode, resolveExportImage])
@@ -820,13 +828,15 @@ export default function App() {
if (!tab) return
setExportOpen(false)
try {
const html = await buildExportHtml(tab, imageMode)
if (tauriMode) {
// 先定落点再生成:相对模式要按 HTML 文件所在目录重写图片路径(验收 E3)
const target = await pickSaveHtmlFile(`${tab.title}.html`)
if (!target) return
const html = await buildExportHtml(tab, imageMode, { exportDir: dirOf(target) })
await writeTextFile(target, html, false)
flash(`已导出 HTML:${baseName(target)}`)
} else {
const html = await buildExportHtml(tab, imageMode)
downloadTextFile(`${tab.title}.html`, html, 'text/html;charset=utf-8')
flash('已导出 HTML(浏览器下载)')
}
@@ -837,7 +847,7 @@ export default function App() {
if (!tab) return
setExportOpen(false)
try {
const html = await buildExportHtml(tab, 'embed')
const html = await buildExportHtml(tab, 'embed', { forClipboard: true })
try {
await navigator.clipboard.write([
new ClipboardItem({
@@ -848,7 +858,7 @@ export default function App() {
} catch {
await navigator.clipboard.writeText(html)
}
flash('已复制为 HTML,可粘贴到富文本编辑器')
flash('已复制为 HTML(图片已内嵌、公式为 MathML、Mermaid 为图片),可粘贴到 Word/公众号')
} catch (e) { setError(String(e)) }
}, [tab, buildExportHtml, flash])

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, selectionInTable } from './plugins'
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, selectionInTable } from './plugins'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
export interface CursorInfo { line: number; col: number }
@@ -92,6 +92,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(createCodeBlockViewPlugin())
.use(math)
.use(upload)
.use(createPlainTextPastePlugin())
return editor
}

View File

@@ -2,33 +2,39 @@
// 替代阶段 1 的 execCommand('paste')(WebView2 策略下不可靠)。
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import { Slice, Fragment } from '@milkdown/kit/prose/model'
import type { EditorView } from '@milkdown/kit/prose/view'
interface ViewLike {
state: EditorView['state']
dispatch: EditorView['dispatch']
focus: () => void
}
/** 把多行纯文本按段落插入视图选区处(菜单粘贴与 Ctrl+V 共用)。
* 用开放 Slice(openStart/openEnd=1)替换选区:首尾行与光标前后的原文自然衔接,
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const paragraph = view.state.schema.nodes.paragraph
const parentType = view.state.selection.$from.parent.type.name
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {
const { from, to } = view.state.selection
view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView())
view.focus()
return true
}
const nodes = lines.map((line) => paragraph.create(null, line ? view.state.schema.text(line) : undefined))
const slice = new Slice(Fragment.from(nodes), 1, 1)
view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView())
view.focus()
return true
}
export function insertPlainText(editor: Editor, text: string): boolean {
if (!text) return false
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to } = view.state.selection
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const paragraph = view.state.schema.nodes.paragraph
// 审计修复:多行文本原先被 insertText 塞进单个文本节点,渲染折叠成空格、
// 序列化却保留字面换行,往返不保真。选区在普通段落/标题里时按段落节点插入。
const parentType = view.state.selection.$from.parent.type.name
if (lines.length > 1 && paragraph && (parentType === 'paragraph' || parentType === 'heading')) {
const nodes = lines.map((line) => paragraph.create(null, line ? view.state.schema.text(line) : undefined))
view.dispatch(view.state.tr.replaceSelectionWith(nodes[0]).scrollIntoView())
// replaceSelectionWith 只放第一个段落,其余段落用 insert 续在后面
let pos = view.state.selection.to
const tr = view.state.tr
for (const node of nodes.slice(1)) {
tr.insert(pos, node)
pos += node.nodeSize
}
view.dispatch(tr.scrollIntoView())
view.focus()
return
}
view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView())
view.focus()
insertPlainTextIntoView(ctx.get(editorViewCtx) as unknown as ViewLike, text)
})
return true
}

View File

@@ -8,6 +8,7 @@ import { $prose } from '@milkdown/kit/utils'
import { commandsCtx } from '@milkdown/kit/core'
import { addRowAfterCommand, goToNextTableCellCommand, goToPrevTableCellCommand } from '@milkdown/kit/preset/gfm'
import { isImeComposing } from '../utils/ime'
import { insertPlainTextIntoView } from './paste'
// ————————————————— syntaxReveal —————————————————
@@ -360,3 +361,32 @@ export function createCodeBlockTabPlugin() {
},
}))
}
// ————————————————— 纯文本多行粘贴(验收 C5 二轮) —————————————————
// Milkdown 自带 clipboard 插件把 text/plain 当 Markdown 解析:记事本三行文字是
// 一个段落里的软换行,粘进来就是一行。这里抢先处理「无 HTML、无文件」的多行
// 纯文本,按段落插入;富文本粘贴仍走默认管线。菜单粘贴的 execCommand 兜底
// 触发的也是同一个 paste 事件,一并受益(WebView2 里 clipboard.readText 常被拒,
// 菜单粘贴实际走的就是兜底路径)。
export function createPlainTextPastePlugin() {
return $prose(() => new Plugin({
props: {
// 走 handleDOMEvents 而不是 handlePaste:PM 的事件分发先咨询 handleDOMEvents、
// 再走内置粘贴管线(doPaste → handlePaste),而 Milkdown clipboard 的
// handlePaste 在插件序里靠前、轮不到后注册的 handlePaste(实测验证)。
handleDOMEvents: {
paste: (view, event) => {
const data = event.clipboardData
if (!data) return false
if (data.files && data.files.length > 0) return false
if (data.getData('text/html')) return false
const text = data.getData('text/plain')
if (!text || !/\r|\n/.test(text)) return false
const handled = insertPlainTextIntoView(view as never, text)
if (handled) event.preventDefault()
return handled
},
},
},
}))
}

View File

@@ -26,6 +26,14 @@ export interface ExportOptions {
highlight?: boolean
/** 是否渲染 Mermaid 为 SVG(测试可关) */
renderMermaid?: boolean
/** 源文档所在目录(相对图片按此解析) */
docDir?: string
/** 导出 HTML 将保存到的目录(relative 模式按它重写图片相对路径,验收 E3) */
exportDir?: string
/** 剪贴板版:公式只保留 MathML(去掉无 CSS 的 KaTeX 视觉层,Word 才能转原生公式,验收 E4) */
forClipboard?: boolean
/** Mermaid 输出为 PNG 图片而不是 SVG(Word 不认剪贴板里的 SVG,验收 E4) */
mermaidPng?: boolean
}
interface CodeJob {
@@ -68,6 +76,20 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
}
}
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
// HTML 存到别处就找不到图)
if (opts.imageMode === 'relative' && opts.docDir && opts.exportDir) {
for (const { node } of images) {
const src = node.properties?.src
if (typeof src === 'string' && src && !/^(https?:|data:|\/\/|#|\/)/i.test(src)) {
let decoded = src
try { decoded = decodeURIComponent(src) } catch { /* 保持原样 */ }
const abs = resolveRelPath(opts.docDir, decoded)
node.properties.src = makeRelativePath(opts.exportDir, abs)
}
}
}
// 代码块替换(从后往前,避免索引漂移)
const mermaidJobs = jobs.filter((j) => j.lang === 'mermaid')
const codeJobs = jobs.filter((j) => j.lang !== 'mermaid')
@@ -94,7 +116,10 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
if (job.lang === 'mermaid' && mermaidApi) {
try {
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}-${jobs.indexOf(job)}`, job.code)
rawHtml = `<div class="mermaid-diagram">${svg}</div>`
const png = opts.mermaidPng ? await svgToPngDataUrl(svg) : null
rawHtml = png
? `<img alt="Mermaid 图表" src="${png}" />`
: `<div class="mermaid-diagram">${svg}</div>`
} catch {
rawHtml = null
}
@@ -113,6 +138,95 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
;(job.parent as Element).children.splice(job.index, 1, rawNode)
}
}
// 剪贴板版:公式只保留 MathML 本体。KaTeX 的视觉层是一堆无样式 span,
// 剪贴板片段不带 CSS,粘进 Word 只剩源代码式的碎字符;纯 MathML 则被
// Word 转成原生公式、浏览器原生渲染(验收 E4)。
if (opts.forClipboard) {
const stripKatexVisual = (node: Element | Root): void => {
if (node.type === 'element') {
const classes = node.properties?.className
if (Array.isArray(classes) && classes.includes('katex')) {
const mathml = node.children.find(
(child) => child.type === 'element' && Array.isArray(child.properties?.className)
&& (child.properties.className as string[]).includes('katex-mathml'),
) as Element | undefined
if (mathml) {
node.children = mathml.children as Element['children']
return
}
}
node.children.forEach((child) => {
if (child.type === 'element') stripKatexVisual(child)
})
} else if (node.type === 'root') {
node.children.forEach((child) => {
if (child.type === 'element') stripKatexVisual(child)
})
}
}
stripKatexVisual(tree)
}
}
/** 路径统一成斜杠分段(兼容 Windows 反斜杠与盘符)。 */
function pathSegments(path: string): string[] {
return path.replace(/\\/g, '/').split('/').filter((seg) => seg !== '' && seg !== '.')
}
/** 以 baseDir 为基准解析相对路径,返回规范化绝对路径(斜杠分隔)。 */
export function resolveRelPath(baseDir: string, src: string): string {
const stack = pathSegments(baseDir)
for (const seg of pathSegments(src)) {
if (seg === '..') stack.pop()
else stack.push(seg)
}
const joined = stack.join('/')
return /^[a-zA-Z]:/.test(joined) ? joined : `/${joined}`
}
/** 计算从 fromDir 到 absPath 的相对路径(斜杠分隔);跨盘符时退回 file:/// 绝对地址。 */
export function makeRelativePath(fromDir: string, absPath: string): string {
const from = pathSegments(fromDir)
const to = pathSegments(absPath)
const windows = /^[a-zA-Z]:$/.test(from[0] ?? '') || /^[a-zA-Z]:$/.test(to[0] ?? '')
const eq = (a: string, b: string) => (windows ? a.toLowerCase() === b.toLowerCase() : a === b)
if (windows && from[0] && to[0] && !eq(from[0], to[0])) {
return `file:///${to.map((seg) => encodeURIComponent(seg)).join('/')}`
}
let common = 0
while (common < from.length && common < to.length && eq(from[common], to[common])) common++
const parts = [...Array(from.length - common).fill('..'), ...to.slice(common)]
return parts.map((seg) => (seg === '..' ? '..' : encodeURIComponent(seg))).join('/')
}
/** SVG 栅格化为 PNG data URL(剪贴板给 Word 用);失败返回 null 由调用方退回 SVG。 */
async function svgToPngDataUrl(svg: string): Promise<string | null> {
try {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }))
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('svg load failed'))
img.src = url
})
const scale = 2
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale))
canvas.height = Math.max(1, Math.round((img.naturalHeight || 150) * scale))
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png')
} finally {
URL.revokeObjectURL(url)
}
} catch {
return null
}
}
function hashCode(text: string): number {
@@ -218,13 +332,38 @@ function escapeHtml(text: string): string {
}
/** HTML → Markdown(导入用,懒加载 turndown)。 */
/** HTML 导入预处理(验收 E6 二轮):整页文档直接喂给 turndown 会把 head 里的
* 样式与脚本、SVG 图形翻成代码残片,看起来「全是 HTML 代码」。
* 先用 DOM 清掉这些部分,并把本应用自己导出的 KaTeX 还原回 $...$ 源码、
* Mermaid 图(只有 SVG、源码已不可逆)换成一句说明。 */
export function preprocessHtmlForImport(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('script, style, svg, iframe, noscript, link, meta, title').forEach((el) => el.remove())
doc.querySelectorAll('.katex').forEach((el) => {
const tex = el.querySelector('annotation')?.textContent?.trim()
if (!tex) {
el.remove()
return
}
const display = el.closest('.katex-display') !== null
el.replaceWith(doc.createTextNode(display ? `$$${tex}$$` : `$${tex}$`))
})
doc.querySelectorAll('.mermaid-diagram').forEach((el) => {
el.replaceWith(doc.createTextNode('(Mermaid 图表:导入时无法还原源码)'))
})
return doc.body ? doc.body.innerHTML : html
}
export async function htmlToMarkdown(html: string): Promise<string> {
const TurndownService = (await import('turndown')).default
const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
// GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6)
const { gfm } = await import('turndown-plugin-gfm')
service.use(gfm)
return service.turndown(html)
// GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6);
// 插件加载失败时降级为基础转换而不是让整个导入失败
try {
const { gfm } = await import('turndown-plugin-gfm')
service.use(gfm)
} catch { /* 基础转换仍可用 */ }
return service.turndown(preprocessHtmlForImport(html))
}
/** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */