diff --git a/docs/15-acceptance-fix-round2.md b/docs/15-acceptance-fix-round2.md new file mode 100644 index 0000000..51afd37 --- /dev/null +++ b/docs/15-acceptance-fix-round2.md @@ -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 在段落中间粘贴会留一个序列化为 `
` 的空段,现在首尾行与光标前后原文自然衔接,不产生空段。 +- 菜单粘贴的 execCommand 兜底触发的是同一个 paste 事件,一并受益。 + +**回归测试**:合成 paste 事件粘三行 → 三段;段中粘贴两行 → 无 `
`、前后文衔接正确。 + +## 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。 + +**回归测试**:剪贴板版输出含 `` 且不含 `katex-html`。 +**说明**:Word 对 MathML 的转换是其标准行为,但不同 Word 版本表现可能有别,请复验时留意;若你的 Word 仍显示源码,下一步方案是公式也栅格化成图片(清晰度换兼容性)。 + +## E6 导入 HTML 全是 HTML 代码 + +**根因**:整页 HTML 直接喂给 turndown,它把 head 里的 `
空段)。 */ +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 } diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts index e21fd06..1c4ee92 100644 --- a/src/editor/plugins.ts +++ b/src/editor/plugins.ts @@ -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 + }, + }, + }, + })) +} diff --git a/src/services/exportHtml.ts b/src/services/exportHtml.ts index 54039c7..eacaad4 100644 --- a/src/services/exportHtml.ts +++ b/src/services/exportHtml.ts @@ -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 { } } + // 相对模式按导出文件位置重写图片路径(验收 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 { if (job.lang === 'mermaid' && mermaidApi) { try { const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}-${jobs.indexOf(job)}`, job.code) - rawHtml = `
${svg}
` + const png = opts.mermaidPng ? await svgToPngDataUrl(svg) : null + rawHtml = png + ? `Mermaid 图表` + : `
${svg}
` } catch { rawHtml = null } @@ -113,6 +138,95 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise { ;(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 { + try { + const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })) + try { + const img = new Image() + await new Promise((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 { 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,懒加载)。 */ diff --git a/tests/audit-fix.test.ts b/tests/audit-fix.test.ts index 8d897d2..8e7810d 100644 --- a/tests/audit-fix.test.ts +++ b/tests/audit-fix.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest' import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor' 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 { insertPlainText } from '../src/editor/paste' import { findBlockIndex } from '../src/editor/cursorSync' @@ -123,3 +123,38 @@ describe('验收回修回归', () => { 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('
') + await editor.destroy() + host.remove() + }) +}) diff --git a/tests/stage5.test.ts b/tests/stage5.test.ts index 686b3c9..28d758d 100644 --- a/tests/stage5.test.ts +++ b/tests/stage5.test.ts @@ -52,3 +52,38 @@ describe('导入转换', () => { 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(' { + const md = await htmlToMarkdown('标题' + + '

正文題

第一段

' + + '

第二段

') + expect(md).toContain('# 正文題') + expect(md).toContain('第一段') + expect(md).toContain('第二段') + expect(md).not.toContain('alert') + expect(md).not.toContain('color:red') + }) +})