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

@@ -0,0 +1,58 @@
# new-markdown 二轮验收回修报告(2026-11-14)
对应大王二轮回填的 4 项:C5 粘贴、E3 相对导出、E4 复制到 Word、E6 HTML 导入。本地验证:tsc 0 错误、vitest **79 通过 + 2 跳过**(新增回归 5 例)、`npm run build` 通过。
---
## C5 多行纯文本粘贴仍是一行(Ctrl+V 与菜单粘贴都中)
**根因有两层,这次都修了:**
1. **菜单粘贴在真机上根本没走 insertPlainText**:WebView2 里 `navigator.clipboard.readText()` 通常被拒,菜单粘贴实际掉进 `execCommand('paste')` 兜底 → 走 Milkdown 自带 clipboard 插件,它把纯文本当 Markdown 解析,三行 = 一个段落的软换行。
2. **Ctrl+V 同理**:自带 clipboard 插件直接处理了。
**修法**(`src/editor/plugins.ts` + `paste.ts` + `editor.ts`):
- 新增纯文本粘贴插件,走 PM 的 `handleDOMEvents.paste`——实测发现 `handlePaste` 在插件序里轮不到后注册者(clipboard 插件先处理并返回 true),而 handleDOMEvents 在 PM 事件分发里先于内置粘贴管线。条件:剪贴板**无 HTML、无文件**且纯文本含换行时,按段落插入;富文本/图片粘贴仍走默认管线。
- 插入算法换成**开放 Slice**(openStart/openEnd=1):上一版 replaceSelectionWith + insert 在段落中间粘贴会留一个序列化为 `<br />` 的空段,现在首尾行与光标前后原文自然衔接,不产生空段。
- 菜单粘贴的 execCommand 兜底触发的是同一个 paste 事件,一并受益。
**回归测试**:合成 paste 事件粘三行 → 三段;段中粘贴两行 → 无 `<br />`、前后文衔接正确。
## E3 导出 HTML(相对路径模式)图片不显示
**根因**:相对模式原样保留了文档里的相对路径(如 `doc.assets/x.png`),它只相对于 **.md 文件**成立;HTML 被另存到别的目录时,浏览器按 HTML 自己的位置找图,自然找不到。
**修法**(`exportHtml.ts` + `App.tsx`):
- 导出改为**先选保存位置、再生成 HTML**,生成时按「导出文件所在目录 → 图片实际位置(源文档目录解析)」重写 src,例如文档在 `C:\ws\docs`、HTML 存 `C:\out` 时写成 `../ws/docs/doc.assets/x.png`;HTML 与文档同目录时保持原样。路径段做 URL 编码,跨盘符退回 `file:///` 绝对地址。
**回归测试**:跨目录重写与同目录不变两例。
## E4 复制为 HTML 粘到 Word,公式和 Mermaid 都是代码
**根因**:剪贴板片段**不带 CSS**。KaTeX 的视觉层是一堆 span,没样式时在 Word 里塌成源代码式的碎字符;Mermaid 是 SVG,Word 不认剪贴板 HTML 里的 SVG。(图片内嵌本身没问题,原先就是 embed 模式。)
**修法**(剪贴板专用变体,文件导出不变):
- 公式:只保留 **MathML 本体**、剥掉 KaTeX 视觉层——Word 会把 MathML 转成原生公式,浏览器也能原生渲染 MathML。
- Mermaid:渲染出的 SVG **栅格化为 PNG 图片**(2 倍图、白底)再放进剪贴板;栅格化失败才退回 SVG。
**回归测试**:剪贴板版输出含 `<math>` 且不含 `katex-html`。
**说明**:Word 对 MathML 的转换是其标准行为,但不同 Word 版本表现可能有别,请复验时留意;若你的 Word 仍显示源码,下一步方案是公式也栅格化成图片(清晰度换兼容性)。
## E6 导入 HTML 全是 HTML 代码
**根因**:整页 HTML 直接喂给 turndown,它把 head 里的 `<style>`/`<title>` 文本、`<script>`、SVG 都翻成了正文文本,看起来「全是代码」;从记事本/浏览器另存的整页文件都会中。若导入的是**本应用自己导出的 HTML**,KaTeX 的 span 与 Mermaid 的 SVG 同样会被翻成残片。
**修法**(`exportHtml.ts`):
- 导入前用 DOM 预处理:删 script/style/svg/iframe/head 杂项;本应用导出的公式按 annotation 还原回 `$...$`(块公式 `$$...$$`);Mermaid 图换成一句「导入时无法还原源码」的说明(SVG 里没有源码,不可逆)。
- GFM 插件加载失败时降级为基础转换,不再让整个导入失败。
**回归测试**:整页 HTML(含 head/style/script/svg)导入后只剩正文结构。
---
## 复验建议(Windows)
- **C5**:记事本三行,Ctrl+V 一次、菜单粘贴一次,都应是三段;在一段文字中间粘两行,前后应衔接、无空行。
- **E3**:相对模式导出到**与文档不同的目录**(如桌面),浏览器打开图片应显示;同目录再导一份对照。
- **E4**:复制为 HTML → 粘 Word:公式应是可编辑的原生公式、Mermaid 应是图片;再粘公众号编辑器看一眼。
- **E6**:导入一个浏览器另存的整页 HTML(或上一轮导出的那份),应只有正文;表格仍在。

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

View File

@@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { indent } from '@milkdown/kit/plugin/indent' import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math' import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' 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' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
export interface CursorInfo { line: number; col: number } export interface CursorInfo { line: number; col: number }
@@ -92,6 +92,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(createCodeBlockViewPlugin()) .use(createCodeBlockViewPlugin())
.use(math) .use(math)
.use(upload) .use(upload)
.use(createPlainTextPastePlugin())
return editor return editor
} }

View File

@@ -2,33 +2,39 @@
// 替代阶段 1 的 execCommand('paste')(WebView2 策略下不可靠)。 // 替代阶段 1 的 execCommand('paste')(WebView2 策略下不可靠)。
import type { Editor } from '@milkdown/kit/core' import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } 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 { export function insertPlainText(editor: Editor, text: string): boolean {
if (!text) return false if (!text) return false
editor.action((ctx) => { editor.action((ctx) => {
const view = ctx.get(editorViewCtx) insertPlainTextIntoView(ctx.get(editorViewCtx) as unknown as ViewLike, text)
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()
}) })
return true return true
} }

View File

@@ -8,6 +8,7 @@ import { $prose } from '@milkdown/kit/utils'
import { commandsCtx } from '@milkdown/kit/core' import { commandsCtx } from '@milkdown/kit/core'
import { addRowAfterCommand, goToNextTableCellCommand, goToPrevTableCellCommand } from '@milkdown/kit/preset/gfm' import { addRowAfterCommand, goToNextTableCellCommand, goToPrevTableCellCommand } from '@milkdown/kit/preset/gfm'
import { isImeComposing } from '../utils/ime' import { isImeComposing } from '../utils/ime'
import { insertPlainTextIntoView } from './paste'
// ————————————————— syntaxReveal ————————————————— // ————————————————— 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 highlight?: boolean
/** 是否渲染 Mermaid 为 SVG(测试可关) */ /** 是否渲染 Mermaid 为 SVG(测试可关) */
renderMermaid?: boolean 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 { 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 mermaidJobs = jobs.filter((j) => j.lang === 'mermaid')
const codeJobs = 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) { if (job.lang === 'mermaid' && mermaidApi) {
try { try {
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}-${jobs.indexOf(job)}`, job.code) 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 { } catch {
rawHtml = null 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) ;(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 { function hashCode(text: string): number {
@@ -218,13 +332,38 @@ function escapeHtml(text: string): string {
} }
/** HTML → Markdown(导入用,懒加载 turndown)。 */ /** 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> { export async function htmlToMarkdown(html: string): Promise<string> {
const TurndownService = (await import('turndown')).default const TurndownService = (await import('turndown')).default
const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' }) const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
// GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6) // GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6);
const { gfm } = await import('turndown-plugin-gfm') // 插件加载失败时降级为基础转换而不是让整个导入失败
service.use(gfm) try {
return service.turndown(html) const { gfm } = await import('turndown-plugin-gfm')
service.use(gfm)
} catch { /* 基础转换仍可用 */ }
return service.turndown(preprocessHtmlForImport(html))
} }
/** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */ /** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */

View File

@@ -2,7 +2,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor' import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor'
import { editorViewCtx } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core'
import { AllSelection, NodeSelection } from '@milkdown/kit/prose/state' import { AllSelection, NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
import { buildTextIndex, findInIndex } from '../src/editor/search' import { buildTextIndex, findInIndex } from '../src/editor/search'
import { insertPlainText } from '../src/editor/paste' import { insertPlainText } from '../src/editor/paste'
import { findBlockIndex } from '../src/editor/cursorSync' import { findBlockIndex } from '../src/editor/cursorSync'
@@ -123,3 +123,38 @@ describe('验收回修回归', () => {
expect(md).toContain('| 1 | 2 |') expect(md).toContain('| 1 | 2 |')
}) })
}) })
describe('二轮验收回归', () => {
it('Ctrl+V 粘贴记事本三行纯文本分成三段(C5)', async () => {
const { host, editor } = await makeEditor('已有文字')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 5)))
})
const view = editor.action((ctx) => ctx.get(editorViewCtx))
const event = new Event('paste', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'clipboardData', {
value: { files: [], getData: (type: string) => (type === 'text/plain' ? '第一行\r\n第二行\r\n第三行' : '') },
})
view.dom.dispatchEvent(event)
const md = getMarkdown(editor)
expect(md).toContain('第一行\n\n第二行\n\n第三行')
await editor.destroy()
host.remove()
})
it('段落中间粘贴多行不产生空段落(C5)', async () => {
const { host, editor } = await makeEditor('前后文')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)))
})
const { insertPlainText } = await import('../src/editor/paste')
insertPlainText(editor, '第一行\n第二行')
const md = getMarkdown(editor)
expect(md).toContain('前第一行\n\n第二行后文')
expect(md).not.toContain('<br />')
await editor.destroy()
host.remove()
})
})

View File

@@ -52,3 +52,38 @@ describe('导入转换', () => {
expect(md).toContain('**加粗**') expect(md).toContain('**加粗**')
}) })
}) })
describe('二轮验收:导出与导入', () => {
it('相对路径按导出文件位置重写(E3)', async () => {
const html = await markdownToExportHtml('# 图\n\n![示意图](doc.assets/pic%20one.png)\n', {
title: 't', themeId: 'default', imageMode: 'relative',
docDir: 'C:/ws/docs', exportDir: 'C:/out', highlight: false, renderMermaid: false,
})
expect(html).toContain('src="../ws/docs/doc.assets/pic%20one.png"')
const sameDir = await markdownToExportHtml('![a](img.png)', {
title: 't', themeId: 'default', imageMode: 'relative',
docDir: 'C:/ws', exportDir: 'C:/ws', highlight: false, renderMermaid: false,
})
expect(sameDir).toContain('src="img.png"')
})
it('剪贴板版公式只剩 MathML(E4)', async () => {
const html = await markdownToExportHtml('公式 $x=1$ 在此\n', {
title: 't', themeId: 'default', imageMode: 'embed', forClipboard: true,
highlight: false, renderMermaid: false,
})
expect(html).toContain('<math')
expect(html).not.toContain('katex-html')
})
it('导入整页 HTML 先剥 head/脚本/SVG(E6)', async () => {
const md = await htmlToMarkdown('<html><head><title>标题</title><style>.a{color:red}</style></head>'
+ '<body><h1>正文題</h1><script>alert(1)</script><p>第一段</p>'
+ '<svg><rect width="10"/></svg><p>第二段</p></body></html>')
expect(md).toContain('# 正文題')
expect(md).toContain('第一段')
expect(md).toContain('第二段')
expect(md).not.toContain('alert')
expect(md).not.toContain('color:red')
})
})