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:
58
docs/15-acceptance-fix-round2.md
Normal file
58
docs/15-acceptance-fix-round2.md
Normal 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(或上一轮导出的那份),应只有正文;表格仍在。
|
||||||
18
src/App.tsx
18
src/App.tsx
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,懒加载)。 */
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -52,3 +52,38 @@ describe('导入转换', () => {
|
|||||||
expect(md).toContain('**加粗**')
|
expect(md).toContain('**加粗**')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('二轮验收:导出与导入', () => {
|
||||||
|
it('相对路径按导出文件位置重写(E3)', async () => {
|
||||||
|
const html = await markdownToExportHtml('# 图\n\n\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('', {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user