docs: 阶段0 技术验证(选型/架构/原型与验证记录)

This commit is contained in:
Muse
2026-11-13 21:19:23 +08:00
commit cbd7ce4b44
25 changed files with 5856 additions and 0 deletions

2
prototype/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

5
prototype/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

26
prototype/package.json Normal file
View 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
View 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
View 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
View 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[结束]
\`\`\`
![示例图片](images/demo.png)
###### 六级标题
`
export const IME_MD = `# 中文输入验证
在这里进行中文输入法组合输入验证:拼音组合过程中不应提前触发格式转换。
`

6
prototype/src/main.tsx Normal file
View 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
View 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%; }

View 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)
})
})

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()] })

View File

@@ -0,0 +1,4 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], testTimeout: 20000 },
})