docs: 阶段0 技术验证(选型/架构/原型与验证记录)
This commit is contained in:
2
prototype/.gitignore
vendored
Normal file
2
prototype/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
5
prototype/index.html
Normal file
5
prototype/index.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>new-markdown · 阶段0 验证原型</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
4916
prototype/package-lock.json
generated
Normal file
4916
prototype/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
prototype/package.json
Normal file
26
prototype/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "new-markdown-stage0-prototype",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@milkdown/kit": "7.23.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.11",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"jsdom": "25.0.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.11",
|
||||
"vitest": "2.1.5"
|
||||
}
|
||||
}
|
||||
136
prototype/src/App.tsx
Normal file
136
prototype/src/App.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown, callUndo, callRedo } from './editor'
|
||||
import { BASIC_MD, COMPLEX_MD, IME_MD } from './fixtures'
|
||||
|
||||
// 阶段 0 交互原型:只验证布局结构与编辑器挂载点,不是正式界面。
|
||||
// 菜单里只有原型内真实可用的动作;其余菜单按需求在后续阶段逐项落地,不放假按钮。
|
||||
|
||||
type SampleKey = 'basic' | 'complex' | 'ime'
|
||||
const SAMPLES: Record<SampleKey, { label: string; md: string }> = {
|
||||
basic: { label: '基础结构示例', md: BASIC_MD },
|
||||
complex: { label: '复杂结构示例', md: COMPLEX_MD },
|
||||
ime: { label: '中文输入验证页', md: IME_MD },
|
||||
}
|
||||
|
||||
function countWords(md: string): number {
|
||||
const text = md.replace(/```[\s\S]*?```/g, ' ')
|
||||
const cjk = (text.match(/[\u4e00-\u9fff]/g) ?? []).length
|
||||
const words = (text.replace(/[\u4e00-\u9fff]/g, ' ').match(/[A-Za-z0-9]+/g) ?? []).length
|
||||
return cjk + words
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [sample, setSample] = useState<SampleKey>('complex')
|
||||
const [markdown, setMarkdown] = useState(COMPLEX_MD)
|
||||
const [sourceMode, setSourceMode] = useState(false)
|
||||
const [sourceText, setSourceText] = useState('')
|
||||
const [showOutput, setShowOutput] = useState(true)
|
||||
const [sidebar, setSidebar] = useState(true)
|
||||
|
||||
const outline = useMemo(() => {
|
||||
const items: { level: number; text: string }[] = []
|
||||
for (const line of markdown.split('\n')) {
|
||||
const m = /^(#{1,6})\s+(.+)$/.exec(line)
|
||||
if (m) items.push({ level: m[1].length, text: m[2].trim() })
|
||||
}
|
||||
return items
|
||||
}, [markdown])
|
||||
|
||||
// 挂载/重挂载编辑器(切换示例时整篇替换 —— 原型简化,正式版走标签页文档模型)
|
||||
useEffect(() => {
|
||||
if (sourceMode || !hostRef.current) return
|
||||
const editor = createMarkdownEditor(hostRef.current, SAMPLES[sample].md, setMarkdown)
|
||||
editorRef.current = editor
|
||||
editor.create().then(() => setMarkdown(getMarkdown(editor))).catch(console.error)
|
||||
return () => { editor.destroy().catch(() => undefined); editorRef.current = null }
|
||||
}, [sample, sourceMode])
|
||||
|
||||
const toggleSourceMode = () => {
|
||||
if (!sourceMode && editorRef.current) {
|
||||
setSourceText(getMarkdown(editorRef.current))
|
||||
setSourceMode(true)
|
||||
} else {
|
||||
setMarkdown(sourceText)
|
||||
// 以源码文本重建编辑器:把 sample 切到当前键并用 sourceText 覆盖初始值
|
||||
SAMPLES[sample] = { ...SAMPLES[sample], md: sourceText }
|
||||
setSourceMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadMarkdown = () => {
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'new-markdown-stage0.md'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const jumpToHeading = (text: string) => {
|
||||
const els = hostRef.current?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? []
|
||||
for (const el of els) { if (el.textContent?.trim() === text) { el.scrollIntoView({ block: 'center' }); break } }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="titlebar">
|
||||
<span className="logo">M↓</span>
|
||||
<span className="doc-name">new-markdown · 阶段 0 验证原型</span>
|
||||
<span className="titlebar-note">正式版标题栏/窗口控制在阶段 1 由 Tauri 实现</span>
|
||||
</header>
|
||||
<nav className="menubar">
|
||||
<button onClick={() => setSample('basic')}>示例·基础</button>
|
||||
<button onClick={() => setSample('complex')}>示例·复杂</button>
|
||||
<button onClick={() => setSample('ime')}>示例·中文输入</button>
|
||||
<span className="sep" />
|
||||
<button onClick={() => editorRef.current && callUndo(editorRef.current)}>撤销</button>
|
||||
<button onClick={() => editorRef.current && callRedo(editorRef.current)}>重做</button>
|
||||
<button onClick={toggleSourceMode}>{sourceMode ? '切回即时渲染' : '源代码模式'}</button>
|
||||
<button onClick={() => setSidebar(!sidebar)}>{sidebar ? '隐藏侧边栏' : '显示侧边栏'}</button>
|
||||
<button onClick={() => setShowOutput(!showOutput)}>{showOutput ? '隐藏序列化输出' : '显示序列化输出'}</button>
|
||||
<button onClick={downloadMarkdown}>导出当前 Markdown</button>
|
||||
</nav>
|
||||
<div className="main">
|
||||
{sidebar && (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-title">文档大纲(随内容实时生成)</div>
|
||||
{outline.length === 0 && <div className="sidebar-empty">暂无标题</div>}
|
||||
{outline.map((o, i) => (
|
||||
<button key={i} className="outline-item" style={{ paddingLeft: 12 + (o.level - 1) * 14 }} onClick={() => jumpToHeading(o.text)}>{o.text}</button>
|
||||
))}
|
||||
<div className="sidebar-title" style={{ marginTop: 18 }}>验证清单</div>
|
||||
<ul className="checklist">
|
||||
<li>输入 # + 空格 → 标题即时渲染</li>
|
||||
<li>输入 - [ ] → 任务列表,可点击勾选</li>
|
||||
<li>在表格单元格内编辑,Tab 跳格</li>
|
||||
<li>Ctrl+Z / Ctrl+Y 撤销重做</li>
|
||||
<li>中文拼音输入全程组合,不提前上屏</li>
|
||||
<li>右侧序列化输出与编辑实时同步</li>
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
<section className="editor-pane">
|
||||
{sourceMode
|
||||
? <textarea className="source" value={sourceText} onChange={(e) => { setSourceText(e.target.value); setMarkdown(e.target.value) }} spellCheck={false} />
|
||||
: <div className="editor-host" ref={hostRef} />}
|
||||
</section>
|
||||
{showOutput && !sourceMode && (
|
||||
<aside className="output-pane">
|
||||
<div className="sidebar-title">序列化输出(保存到 .md 的实际内容)</div>
|
||||
<pre>{markdown}</pre>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
<footer className="statusbar">
|
||||
<span>字数:{countWords(markdown)}</span>
|
||||
<span>字符:{markdown.length}</span>
|
||||
<span>行:{markdown.split('\n').length}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>{sourceMode ? '源代码模式' : '即时渲染模式'}</span>
|
||||
<span className="status-right">内核:Milkdown 7.23(ProseMirror)· 阶段 0 原型</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
prototype/src/editor.ts
Normal file
50
prototype/src/editor.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// 阶段 0 验证用编辑器工厂:UI 与自动化测试共用同一条创建路径,
|
||||
// 保证「测过的就是演示的」。正式产品阶段在此之上叠加主题、数学、Mermaid 等插件,
|
||||
// 不改内核选型。
|
||||
import { Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, commandsCtx, remarkStringifyOptionsCtx } from '@milkdown/kit/core'
|
||||
import { commonmark } from '@milkdown/kit/preset/commonmark'
|
||||
import { gfm } from '@milkdown/kit/preset/gfm'
|
||||
import { history, undoCommand, redoCommand } from '@milkdown/kit/plugin/history'
|
||||
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
|
||||
import { clipboard } from '@milkdown/kit/plugin/clipboard'
|
||||
import { indent } from '@milkdown/kit/plugin/indent'
|
||||
|
||||
export type MarkdownListener = (markdown: string) => void
|
||||
|
||||
export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, onUpdate?: MarkdownListener) {
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root)
|
||||
ctx.set(defaultValueCtx, initialMarkdown)
|
||||
// 序列化风格对齐 Typora 常见写法:无序列表用 `-`,减少保存时与原文的无谓 diff。
|
||||
// 注意:首轮往返仍会对个别写法做规范化(如表格对齐冒号),第二轮起逐字稳定(测试已覆盖)。
|
||||
ctx.set(remarkStringifyOptionsCtx, { bullet: '-' })
|
||||
if (onUpdate) {
|
||||
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => onUpdate(markdown))
|
||||
}
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(clipboard)
|
||||
.use(indent)
|
||||
return editor
|
||||
}
|
||||
|
||||
/** 把当前文档模型序列化回 Markdown —— 保存路径的核心动作。 */
|
||||
export function getMarkdown(editor: Editor): string {
|
||||
return editor.action((ctx) => {
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
const view = ctx.get(editorViewCtx)
|
||||
return serializer(view.state.doc)
|
||||
})
|
||||
}
|
||||
|
||||
export function callUndo(editor: Editor) {
|
||||
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
||||
}
|
||||
|
||||
export function callRedo(editor: Editor) {
|
||||
editor.action((ctx) => ctx.get(commandsCtx).call(redoCommand.key))
|
||||
}
|
||||
65
prototype/src/fixtures.ts
Normal file
65
prototype/src/fixtures.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// 阶段 0 验证语料:UI 演示与自动化测试共用。
|
||||
// 覆盖需求文档 §5 的核心结构(数学/Mermaid 在阶段 3 接入专用插件,
|
||||
// 这里先验证其原始文本不会在 CommonMark/GFM 往返中丢失)。
|
||||
|
||||
export const BASIC_MD = `# 一级标题示例
|
||||
|
||||
这是一个普通段落,包含**粗体**、*斜体*、~~删除线~~和 \`行内代码\`,还有一个[链接](https://example.com)。
|
||||
|
||||
## 二级标题
|
||||
|
||||
> 这是一段引用。
|
||||
>
|
||||
> 引用中的第二段。
|
||||
|
||||
- 无序列表第一项
|
||||
- 无序列表第二项
|
||||
- 嵌套列表项
|
||||
|
||||
1. 有序列表第一项
|
||||
2. 有序列表第二项
|
||||
|
||||
---
|
||||
|
||||
段落结尾。
|
||||
`
|
||||
|
||||
export const COMPLEX_MD = `# 复杂文档验证
|
||||
|
||||
## 任务列表
|
||||
|
||||
- [ ] 待完成事项
|
||||
- [x] 已完成事项
|
||||
|
||||
## 表格
|
||||
|
||||
| 名称 | 类型 | 数量 |
|
||||
| :--- | :---: | ---: |
|
||||
| 苹果 | 水果 | 3 |
|
||||
| 香蕉 | 水果 | 5 |
|
||||
|
||||
## 代码块
|
||||
|
||||
\`\`\`typescript
|
||||
const greeting: string = "你好,世界";
|
||||
console.log(greeting);
|
||||
\`\`\`
|
||||
|
||||
## 数学与图表原始文本(阶段 3 专用插件接入前,先保真)
|
||||
|
||||
行内公式原始文本:$E = mc^2$ 应被完整保留。
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A[开始] --> B[结束]
|
||||
\`\`\`
|
||||
|
||||

|
||||
|
||||
###### 六级标题
|
||||
`
|
||||
|
||||
export const IME_MD = `# 中文输入验证
|
||||
|
||||
在这里进行中文输入法组合输入验证:拼音组合过程中不应提前触发格式转换。
|
||||
`
|
||||
6
prototype/src/main.tsx
Normal file
6
prototype/src/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
// 不用 StrictMode:其开发期双重挂载会让 Milkdown 编辑器重复创建(阶段 0 原型简化处理)。
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
38
prototype/src/styles.css
Normal file
38
prototype/src/styles.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/* 阶段 0 原型样式:只验证 §3 布局与排版方向(中性、克制、内容居中限宽),不是正式主题系统。 */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif; color: #24292f; background: #f6f6f4; }
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
.titlebar { display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 12px; background: #ececea; border-bottom: 1px solid #dddeda; font-size: 13px; }
|
||||
.logo { font-weight: 700; background: #43536a; color: #fff; border-radius: 6px; padding: 2px 7px; }
|
||||
.titlebar-note { margin-left: auto; color: #8a8f98; font-size: 12px; }
|
||||
.menubar { display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: #fbfbfa; border-bottom: 1px solid #e3e3e0; }
|
||||
.menubar button { border: 0; background: transparent; padding: 5px 9px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.menubar button:hover { background: #eee; }
|
||||
.menubar .sep { width: 1px; height: 18px; background: #ddd; margin: 0 4px; }
|
||||
.main { flex: 1; display: flex; min-height: 0; }
|
||||
.sidebar { width: 240px; flex: none; padding: 14px 10px; background: #f0f0ee; border-right: 1px solid #e0e0dc; overflow-y: auto; }
|
||||
.sidebar-title { font-size: 12px; color: #6b7280; margin-bottom: 8px; }
|
||||
.sidebar-empty { font-size: 13px; color: #9aa0a6; }
|
||||
.outline-item { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.outline-item:hover { background: #e6e6e2; }
|
||||
.checklist { margin: 0; padding-left: 18px; font-size: 12px; color: #555; line-height: 1.9; }
|
||||
.editor-pane { flex: 1; overflow-y: auto; background: #fff; }
|
||||
.editor-host { max-width: 860px; margin: 0 auto; padding: 36px 48px 120px; }
|
||||
.output-pane { width: 360px; flex: none; border-left: 1px solid #e3e3e0; background: #fafafa; padding: 14px; overflow-y: auto; }
|
||||
.output-pane pre { font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
.source { width: 100%; height: 100%; border: 0; outline: none; resize: none; padding: 32px 48px; font-family: "Cascadia Code", Consolas, monospace; font-size: 14px; line-height: 1.7; }
|
||||
.statusbar { display: flex; gap: 18px; align-items: center; height: 26px; padding: 0 12px; background: #ececea; border-top: 1px solid #dddeda; font-size: 12px; color: #555; }
|
||||
.status-right { margin-left: auto; }
|
||||
|
||||
/* ——— Milkdown/ProseMirror 基础排版(原型级,正式主题在阶段 5) ——— */
|
||||
.milkdown .ProseMirror { outline: none; font-size: 16px; line-height: 1.75; }
|
||||
.milkdown .ProseMirror h1 { font-size: 30px; border-bottom: 1px solid #e5e5e2; padding-bottom: 8px; }
|
||||
.milkdown .ProseMirror h2 { font-size: 24px; }
|
||||
.milkdown .ProseMirror blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid #c9cdd4; color: #57606a; }
|
||||
.milkdown .ProseMirror code { background: #f0f0ee; border-radius: 6px; padding: 2px 5px; font-family: "Cascadia Code", Consolas, monospace; font-size: 90%; }
|
||||
.milkdown .ProseMirror pre { background: #f6f6f4; border-radius: 8px; padding: 14px 16px; overflow-x: auto; }
|
||||
.milkdown .ProseMirror pre code { background: transparent; padding: 0; }
|
||||
.milkdown .ProseMirror table { border-collapse: collapse; }
|
||||
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid #d8d8d4; padding: 6px 12px; }
|
||||
.milkdown .ProseMirror img { max-width: 100%; }
|
||||
62
prototype/tests/editing.test.ts
Normal file
62
prototype/tests/editing.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// 阶段 0 编辑行为验收:撤销/重做、程序化插入后的选区落点、输入规则即时渲染。
|
||||
// IME 组合输入无法在 jsdom 中真实模拟(需要真实输入法),本文件只验证
|
||||
// ProseMirror 的 composition 处理未被上层覆盖(见阶段 0 报告中的人工验证清单)。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown, callUndo, callRedo } from '../src/editor'
|
||||
|
||||
let editors: Editor[] = []
|
||||
afterEach(async () => {
|
||||
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
|
||||
editors = []
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function load(markdown: string): Promise<Editor> {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, markdown)
|
||||
editors.push(editor)
|
||||
await editor.create()
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('编辑行为', () => {
|
||||
it('撤销恢复到原文,重做再次应用修改', async () => {
|
||||
const editor = await load('原始内容\n')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.insertText('追加文字', view.state.doc.content.size))
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('追加文字')
|
||||
callUndo(editor)
|
||||
expect(getMarkdown(editor)).not.toContain('追加文字')
|
||||
expect(getMarkdown(editor)).toContain('原始内容')
|
||||
callRedo(editor)
|
||||
expect(getMarkdown(editor)).toContain('追加文字')
|
||||
})
|
||||
|
||||
it('插入后选区落在文档模型内的合法位置,文档可继续序列化', async () => {
|
||||
const editor = await load('# 标题\n\n正文段落。\n')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const pos = view.state.doc.content.size
|
||||
view.dispatch(view.state.tr.insertText('尾巴'))
|
||||
const sel = view.state.selection
|
||||
expect(sel.from).toBeGreaterThan(0)
|
||||
expect(sel.from).toBeLessThanOrEqual(view.state.doc.content.size + 1)
|
||||
expect(pos).toBeGreaterThan(0)
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('尾巴')
|
||||
})
|
||||
|
||||
it('编辑器 DOM 为 contenteditable 且启用了 ProseMirror 历史栈', async () => {
|
||||
const editor = await load('检查 DOM\n')
|
||||
const ok = editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
return view.dom.getAttribute('contenteditable') === 'true'
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
})
|
||||
78
prototype/tests/roundtrip.test.ts
Normal file
78
prototype/tests/roundtrip.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// 阶段 0 核心验收:Markdown -> 文档模型 -> Markdown 双向转换保真。
|
||||
// 断言分两层:
|
||||
// 1) 内容保真:关键文本/结构在序列化结果中仍存在(允许语法规范化,如列表符号统一);
|
||||
// 2) 往返稳定:第二轮 parse(serialize(x)) 再 serialize 与第一轮结果逐字一致
|
||||
// —— 这是「保存后重开不漂移」的硬指标。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor'
|
||||
import { BASIC_MD, COMPLEX_MD } from '../src/fixtures'
|
||||
|
||||
let editors: Editor[] = []
|
||||
afterEach(async () => {
|
||||
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
|
||||
editors = []
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function load(markdown: string): Promise<Editor> {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, markdown)
|
||||
editors.push(editor)
|
||||
await editor.create()
|
||||
return editor
|
||||
}
|
||||
|
||||
async function roundtrip(markdown: string): Promise<{ once: string; twice: string }> {
|
||||
const first = await load(markdown)
|
||||
const once = getMarkdown(first)
|
||||
const second = await load(once)
|
||||
const twice = getMarkdown(second)
|
||||
return { once, twice }
|
||||
}
|
||||
|
||||
describe('Markdown 双向转换', () => {
|
||||
it('基础结构保真:标题/粗斜体/删除线/行内代码/引用/列表/分割线/链接', async () => {
|
||||
const { once, twice } = await roundtrip(BASIC_MD)
|
||||
expect(once).toMatch(/^# 一级标题示例/m)
|
||||
expect(once).toMatch(/^## 二级标题/m)
|
||||
expect(once).toContain('**粗体**')
|
||||
expect(once).toContain('删除线')
|
||||
expect(once).toContain('`行内代码`')
|
||||
expect(once).toContain('这是一段引用')
|
||||
expect(once).toContain('无序列表第一项')
|
||||
expect(once).toContain('嵌套列表项')
|
||||
expect(once).toContain('有序列表第二项')
|
||||
expect(once).toContain('https://example.com')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it('复杂结构保真:任务列表状态/表格/代码块语言与内容/图片/六级标题', async () => {
|
||||
const { once, twice } = await roundtrip(COMPLEX_MD)
|
||||
// 任务列表:未完成与已完成两种状态都必须保留
|
||||
expect(once).toMatch(/-\s\[ \]\s待完成事项/)
|
||||
expect(once).toMatch(/-\s\[[xX]\]\s已完成事项/)
|
||||
// 表格:表头、单元格与右对齐列
|
||||
expect(once).toContain('名称')
|
||||
expect(once).toContain('苹果')
|
||||
expect(once).toContain('香蕉')
|
||||
// 代码块:语言标记与代码原文
|
||||
expect(once).toContain('typescript')
|
||||
expect(once).toContain('const greeting')
|
||||
expect(once).toContain('你好,世界')
|
||||
// 图片路径保留
|
||||
expect(once).toContain('images/demo.png')
|
||||
// 最深标题层级
|
||||
expect(once).toMatch(/^###### 六级标题/m)
|
||||
// Mermaid 源码作为代码块原文保留(渲染在阶段 3)
|
||||
expect(once).toContain('graph TD')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it('空文档与纯中文段落往返稳定', async () => {
|
||||
const { once, twice } = await roundtrip('只有一段中文,没有任何格式标记。\n')
|
||||
expect(once).toContain('只有一段中文')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
})
|
||||
16
prototype/tests/setup.ts
Normal file
16
prototype/tests/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// jsdom 缺失的布局 API 垫片:ProseMirror View 在量测坐标时会调用它们。
|
||||
// 只补「不抛错」的最小实现,不参与任何断言逻辑。
|
||||
type RectLike = { x: number; y: number; width: number; height: number; top: number; left: number; right: number; bottom: number; toJSON: () => object }
|
||||
const emptyRect: RectLike = { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON: () => ({}) }
|
||||
|
||||
if (typeof Range !== 'undefined') {
|
||||
Range.prototype.getClientRects = Range.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
|
||||
Range.prototype.getBoundingClientRect = Range.prototype.getBoundingClientRect ?? (() => emptyRect as DOMRect)
|
||||
}
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.getClientRects = Element.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView ?? (() => undefined)
|
||||
}
|
||||
if (typeof document !== 'undefined' && !document.elementFromPoint) {
|
||||
document.elementFromPoint = () => null
|
||||
}
|
||||
10
prototype/tsconfig.json
Normal file
10
prototype/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext", "moduleResolution": "bundler",
|
||||
"jsx": "react-jsx", "strict": true, "skipLibCheck": true,
|
||||
"noEmit": true, "isolatedModules": true, "esModuleInterop": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
3
prototype/vite.config.ts
Normal file
3
prototype/vite.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({ plugins: [react()] })
|
||||
4
prototype/vitest.config.ts
Normal file
4
prototype/vitest.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], testTimeout: 20000 },
|
||||
})
|
||||
Reference in New Issue
Block a user