docs: 阶段0 技术验证(选型/架构/原型与验证记录)
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
*.tsbuildinfo
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# new-markdown
|
||||
|
||||
Windows 桌面 Markdown 所见即所得编辑器(对标 Typora 的编辑体验,不复制其品牌与图标)。
|
||||
Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。
|
||||
|
||||
## 当前进度
|
||||
|
||||
**阶段 0、1、2、3 已完成(自动化验证全绿),待 Windows 真机验收清单确认。**
|
||||
阶段 3:表格全套、图片/附件落盘、KaTeX 公式、Mermaid 预览,报告见 `docs/07-stage3-report.md`。
|
||||
阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。
|
||||
|
||||
在 Windows 上运行桌面版:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run tauri dev # 需 Rust + VS Build Tools + WebView2
|
||||
```
|
||||
|
||||
浏览器调试 UI:`npm run dev` 后打开 http://localhost:1420(Web 降级模式,状态栏有标注)。
|
||||
|
||||
- 技术选型报告:`docs/01-tech-selection.md`
|
||||
- 架构设计:`docs/02-architecture.md`
|
||||
- 阶段 0 验证报告(实测数据):`docs/03-stage0-verification.md`
|
||||
- UI/交互原型说明:`docs/04-ui-prototype.md`
|
||||
|
||||
## 运行阶段 0 原型
|
||||
|
||||
```bash
|
||||
cd prototype
|
||||
npm install
|
||||
npm run dev # 浏览器打开原型:编辑器 + 大纲 + 实时序列化输出
|
||||
npm test # 双向转换/撤销重做等 6 项自动化验收
|
||||
npm run build # 生产构建
|
||||
```
|
||||
|
||||
## 开发纪律
|
||||
|
||||
分阶段开发,每阶段构建+测试+人工清单齐全后再进下一阶段;
|
||||
编辑器新增语法支持必须先给往返稳定性测试加用例。
|
||||
55
docs/01-tech-selection.md
Normal file
55
docs/01-tech-selection.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 阶段 0 · 技术选型报告
|
||||
|
||||
> 项目:new-markdown(Windows Markdown 所见即所得编辑器)
|
||||
> 日期:2026-11-13 · 状态:阶段 0 已实测,待大王拍板后进入阶段 1
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
| 层 | 选型 | 版本(已核查) | 许可证 |
|
||||
|---|---|---|---|
|
||||
| 桌面框架 | **Tauri 2** | @tauri-apps/api 2.12.4 | Apache-2.0 OR MIT |
|
||||
| 前端 | **React 18 + TypeScript 5 + Vite 5** | 与原型一致 | MIT |
|
||||
| UI | **Tailwind CSS + 自定义组件**(阶段 1 接入) | — | MIT |
|
||||
| 编辑器内核 | **Milkdown 7(基于 ProseMirror)** | @milkdown/kit 7.23.0 | MIT |
|
||||
| Markdown 解析 | **unified / remark(remark-gfm)**(Milkdown 内置管线) | remark-gfm 4.0.1 / unified 11.0.5 | MIT |
|
||||
| 数学公式 | **KaTeX**(阶段 3 接入) | 0.19.1 | MIT |
|
||||
| 图表 | **Mermaid**(阶段 3 接入) | 12.3.0 | MIT |
|
||||
| 代码高亮 | **Shiki**(阶段 3 接入;编辑态高亮走 ProseMirror 装饰) | 4.6.0 | MIT |
|
||||
| 本地数据库 | **SQLite**(rusqlite,随 Tauri 后端,阶段 4 接入) | — | MIT/公有领域 |
|
||||
| 自动更新 | **Tauri Updater**(阶段 7) | — | Apache-2.0 OR MIT |
|
||||
|
||||
全部依赖为 MIT / Apache-2.0,无 GPL 类传染性许可证,无商业授权风险。
|
||||
|
||||
## 2. 编辑器内核对比(本项目成败点,重点核查)
|
||||
|
||||
候选均围绕 ProseMirror 生态或源码编辑生态逐项核查:
|
||||
|
||||
| 维度 | Milkdown 7 ✅ 选用 | 裸 ProseMirror 自研 | Tiptap 3 | CodeMirror 6 |
|
||||
|---|---|---|---|---|
|
||||
| 底层模型 | ProseMirror 文档模型(事务/选区/历史完整) | 同左,但一切自己造 | ProseMirror | 自有文本模型 |
|
||||
| Markdown 双向转换 | **原生**:remark 解析 → PM 文档;serializerCtx 序列化回 md,阶段 0 实测保真(见 03 号报告) | 需自研 parser/serializer 全套,工作量与风险最高 | 官方 Markdown 支持仍属扩展层,往返保真弱于 Milkdown(标题/表格/任务列表需逐项补) | 无文档模型,等于源码模式,做不到 Typora 式即时渲染 |
|
||||
| GFM(表格/任务列表/删除线/脚注) | preset-gfm 开箱,实测全保真 | 自研 | 扩展拼装 | 仅高亮 |
|
||||
| Typora 式输入规则(`# `→标题、`- [ ]`→任务) | commonmark/gfm 预设内置 input rules | 自研 | 部分扩展 | 无 |
|
||||
| IME 组合输入 | 继承 ProseMirror View 的 composition 处理,上层未覆盖(代码核查;真实输入法须 Windows 人工验) | 同左 | 同左 | 另一套机制 |
|
||||
| 撤销/重做 | plugin-history,实测通过 | 自接 history 插件 | 内置 | 内置 |
|
||||
| 表格编辑 | gfm 表格节点 + prosemirror-tables 体系,可扩展浮动工具栏 | 自研 | 扩展 | 无 |
|
||||
| 维护与体积 | 活跃维护(7.23.0,2026 年在更);原型 gzip 194KB(未分包) | — | 活跃;部分高级扩展商业化 | 活跃 |
|
||||
| 风险 | 序列化首轮会规范化个别写法(见 §3);大文档全量加载性能须阶段 6 实测 | 周期不可控,直接违反「先验证再开发」的风险控制目标 | 为达同等 md 保真需自补大量转换逻辑,等于半自研 | 不满足产品定位,排除 |
|
||||
|
||||
**决策:Milkdown 7.23.0(@milkdown/kit 单包入口)**。理由:它是唯一「Markdown 双向转换即产品级能力」的现成内核,且阶段 0 已用与产品同一条代码路径实测通过核心验收项;裸 ProseMirror 自研只在 Milkdown 出现不可绕过的缺陷时作为后备(文档模型同源,迁移成本可控)。
|
||||
|
||||
**不采用 Milkdown Crepe**(官方开箱组件版):其预置 UI/主题与本产品自定义界面、主题系统冲突,阶段 1 起直接用 Kit 组装自己的编辑器装配层。
|
||||
|
||||
## 3. 已知限制与对策(选型时即登记,不等踩坑)
|
||||
|
||||
1. **首轮序列化规范化**:打开一份非规范写法的 md 并保存,个别写法会被规范化(实测:表格对齐 `| :--- |` → `| :- |`、脚注/HTML 等不受影响、无序列表符号已通过 `remarkStringifyOptionsCtx: { bullet: '-' }` 对齐 Typora 习惯)。内容与语义零丢失,第二轮起逐字稳定(自动化测试硬断言)。阶段 2 评估是否引入「未修改段落不重序列化」的原文保真策略。
|
||||
2. **数学/Mermaid/代码高亮不在阶段 0 范围**:实测其原始文本(`$E = mc^2$`、mermaid 代码块)在往返中完整保留,不会丢内容;渲染与编辑插件按需求排在阶段 3(KaTeX/Mermaid/Shiki 均为 MIT,已核查)。
|
||||
3. **脚注**:不在 Milkdown 官方预设宣传清单内,但阶段 0 实测探针显示 remark-gfm 管线可完整往返脚注(定义与引用均保留),阶段 3 复测后转正。
|
||||
4. **大文档性能**:158KB 文档在 jsdom(无布局引擎、显著慢于真实 WebView2)下全量加载约 3.6s、序列化约 0.27s——仅作悲观下界参考,不作为产品指标。编辑期走 ProseMirror 增量事务,不整篇重渲染(架构性质)。真实指标按需求 §20 在阶段 6 用 WebView2 实测 100KB/1MB/5MB 并记录。
|
||||
5. **Tauri 本机验证缺口**:本开发沙箱(Linux)无 Rust 工具链,Tauri 壳层无法在此编译验证;阶段 1 在 Windows 上完成首次 Tauri 构建并作为阶段 1 验收前置(需 Rust stable + VS Build Tools + WebView2,均为 Windows 常规组件)。
|
||||
|
||||
## 4. 桌面与系统层选型说明
|
||||
|
||||
- **Tauri 2 而非 Electron**:安装体量与内存占用显著更小,WebView2 为 Windows 10/11 系统组件;文件/对话框/更新均走官方插件与 Rust 侧命令,符合需求 §2 表格原定方案,无变更。
|
||||
- **SQLite 只存应用数据**(设置/最近文件/工作区/恢复记录),正文永远以 .md 文件为唯一事实源(需求 §18),阶段 4 落地。
|
||||
- **源码模式**:CodeMirror 6(MIT,已核查)承担,阶段 2 与即时渲染共用同一文档入口、切换不丢内容。
|
||||
66
docs/02-architecture.md
Normal file
66
docs/02-architecture.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 阶段 0 · 产品架构设计
|
||||
|
||||
> 对齐需求文档 §1–§19。本文件是阶段 1–7 的施工图:模块边界、数据流、关键机制。
|
||||
> 界面结构与操作逻辑按需求 §3 定稿,本文件不复述菜单清单,只定实现结构。
|
||||
|
||||
## 1. 总体结构
|
||||
|
||||
```text
|
||||
┌──────────────────────────── Tauri 2 应用 ────────────────────────────┐
|
||||
│ 前端 (React + TS) │
|
||||
│ ├─ app/ 窗口装配:标题栏、菜单、标签页、侧边栏、状态栏 │
|
||||
│ ├─ editor/ 编辑器装配层(Milkdown Kit 组装、命令、装饰、序列化) │
|
||||
│ ├─ features/ 文件树 / 大纲 / 查找替换 / 图片 / 导出 / 主题 / 设置 │
|
||||
│ └─ stores/ 文档会话状态(每标签一份 EditorSession) │
|
||||
│ │ invoke / event(Tauri IPC) │
|
||||
│ 后端 (Rust) │
|
||||
│ ├─ fs 命令 读/写(临时文件+原子替换)、目录遍历、监听外部变更 │
|
||||
│ ├─ sqlite 设置 / 最近文件 / 工作区 / 恢复记录(不存正文) │
|
||||
│ └─ export PDF 打印管线、DOCX 生成等重活与系统能力 │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
职责红线:正文只在前端编辑器文档模型与磁盘 .md 之间流动;Rust 侧不解析 Markdown 结构(导出管线除外);SQLite 永不成为正文唯一副本。
|
||||
|
||||
## 2. 编辑器装配层(核心)
|
||||
|
||||
阶段 0 原型 `prototype/src/editor.ts` 即装配层的雏形,正式版按此扩展:
|
||||
|
||||
- **创建工厂**:`createMarkdownEditor(root, markdown, onUpdate)` —— commonmark + gfm + history + listener + clipboard + indent。阶段 2/3 在同一工厂按序叠加:源码模式桥、数学、Mermaid、代码高亮装饰、表格浮动工具栏、图片节点视图。UI 与测试共用工厂,保证测过的即交付的。
|
||||
- **文档模型**:ProseMirror Node 树为唯一编辑态;Markdown 文本只是它的序列化投影。打开 = remark 解析建模;保存 = serializer 序列化。禁止任何模块绕过模型直接拼接 md 文本修改文档(查找替换、标题拖拽排序等结构操作一律走事务)。
|
||||
- **序列化策略**:`remarkStringifyOptionsCtx` 统一配置(当前 `bullet: '-'`);序列化只在保存/导出/源码模式切换时全量执行,编辑期仅 listener 节流通知脏状态,不逐键全量序列化(性能纪律)。
|
||||
- **输入规则与标记显隐**:Typora 式「当前节点显示标记、其余隐藏」用 ProseMirror Decoration 按选区所在节点动态切换(阶段 2 核心工作),不触发文档事务、不进历史栈。
|
||||
- **IME**:不拦截 composition 事件、不在 composition 期间跑输入规则(ProseMirror View 原生语义 + 装配层显式守卫,阶段 2 写进编辑器测试清单并在 Windows 真机用微软拼音/搜狗各验一轮)。
|
||||
- **历史**:history 插件独立栈;格式切换、结构命令均以单事务提交,保证一次 Ctrl+Z = 一次用户动作。
|
||||
|
||||
## 3. 文档会话与数据安全(需求 §8/§17)
|
||||
|
||||
- 每个标签页 = 一个 `EditorSession`:{ 文件路径 | 未命名、文档模型、脏标记、编码/换行符、上次保存哈希 }。
|
||||
- **保存**:序列化 → 写同目录临时文件 → 原子替换 → 更新哈希。全程失败(权限/磁盘满)向用户明确报错,保留脏状态与恢复记录,绝不静默丢弃。
|
||||
- **自动保存**:可配置延迟(默认停顿 2s 后、仅对已命名且无外部冲突的文档),走同一保存管线。
|
||||
- **恢复记录**:每次编辑节流写入 SQLite recovery_records(文档键 + 序列化全文 + 时间戳);正常关闭清除;异常启动时提示恢复。
|
||||
- **外部修改检测**:Rust 监听已打开文件;外部变更且本地无修改 → 静默重载;本地已修改 → 弹窗三选(重载丢弃 / 保留本地另存 / 对比),不自动覆盖任何一方。
|
||||
- **编码**:读入探测 UTF-8 / UTF-8 BOM / GBK(Rust 侧解码),内部统一 UTF-8;保存默认 UTF-8(保留原 BOM 策略可配置),换行符 LF/CRLF 按原文或设置。
|
||||
|
||||
## 4. UI 结构(需求 §3 的实现映射)
|
||||
|
||||
- **窗口**:Tauri 自定义标题栏(decorations 关闭 + 前端标题栏组件),拖动区、双击最大化、最小/最大/关闭走 window API;Win10/11 同一实现。
|
||||
- **布局**:菜单栏(原生菜单语义的自绘实现,保证快捷键与禁用态一致)→ 标签栏 → [可折叠侧边栏 | 编辑区] → 状态栏。侧边栏宽度/状态持久化;编辑区正文容器限宽居中(主题变量控制),侧边栏开合不改变正文宽度设定。
|
||||
- **侧边栏双模式**:文件树(Rust 目录遍历 + 懒加载)/ 大纲(由文档模型标题节点实时投影,点击定位、跟随光标高亮、拖拽排序转结构事务)。
|
||||
- **主题**:两层——应用 UI 主题(CSS 变量)与文档主题(作用域限定在 `.editor-doc` 容器内的 CSS,自定义主题 CSS 经作用域重写后注入,无法污染应用 UI,对应需求 §11.2 红线)。内置 7 主题阶段 5 自行实现。
|
||||
- **设置中心**:左分类右内容,全部落 SQLite settings 表,重启生效;快捷键表独立存储并做冲突检测(需求 §14/§15)。
|
||||
|
||||
## 5. 导入导出(需求 §12/§13,阶段 5)
|
||||
|
||||
- 导出 HTML:由文档模型经 remark 管线生成 + 当前文档主题 CSS 内联,图片相对路径可选打包为独立文件目录或 base64 单文件。
|
||||
- 导出 PDF / 打印:隐藏打印视图 + 专用 print CSS,WebView2 打印管线;页眉页脚/页码走打印参数。
|
||||
- 导出 DOCX:Rust 侧(docx 生成库)按模型结构映射标题/列表/表格/图片。
|
||||
- 导入 TXT/HTML:转 Markdown 后建模;DOCX/EPUB 排后续阶段。
|
||||
|
||||
## 6. 目录结构
|
||||
|
||||
按需求 §22 建议落地,阶段 1 建仓时创建(`src/editor/` 内部细分为 core/extensions/markdown/commands/decorations/serializers)。阶段 0 仓库当前只有 `docs/`、`prototype/`、`samples/`,不预铺正式工程空架子。
|
||||
|
||||
## 7. 阶段门禁
|
||||
|
||||
每阶段:构建通过 + 该阶段自动化测试 + 人工验收清单(见 03 号报告模板)→ 大王确认 → 下一阶段。编辑器相关阶段额外要求:往返稳定性测试套件(阶段 0 已建)必须全绿,新增语法支持必须先给套件加用例。
|
||||
62
docs/03-stage0-verification.md
Normal file
62
docs/03-stage0-verification.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 阶段 0 · 技术验证报告(实测记录)
|
||||
|
||||
> 验证时间:2026-11-13 · 环境:Linux 沙箱 Node v24.22.1(前端层验证);Windows/WebView2/真实输入法为人工验证项,见 §4。
|
||||
> 原型位置:`prototype/`(Vite + React 18 + TS + Milkdown 7.23.0),UI 与测试共用 `src/editor.ts` 同一编辑器工厂。
|
||||
|
||||
## 1. 构建与自动化测试结果
|
||||
|
||||
| 项 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 依赖安装 | `npm install` | ✅(vite 降至 5.4.11 以匹配 @vitejs/plugin-react 4.3.3 的 peer 范围,已在 package.json 锁定) |
|
||||
| 类型检查 | `npx tsc -b` | ✅ 0 错误 |
|
||||
| 自动化测试 | `npx vitest run` | ✅ **6/6 通过**(2 文件,约 2.5s) |
|
||||
| 生产构建 | `npm run build` | ✅ 产物 JS 623KB / gzip 194KB(未分包,阶段 6 做代码分割) |
|
||||
|
||||
## 2. 自动化用例明细(tests/)
|
||||
|
||||
**roundtrip.test.ts(双向转换,核心验收)**
|
||||
1. ✅ 基础结构:H1/H2、粗体、斜体标记、删除线、行内代码、引用(含多段)、无序/有序/嵌套列表、分割线、链接 —— 内容保真,且第二轮往返与第一轮逐字一致。
|
||||
2. ✅ 复杂结构:任务列表 `[ ]`/`[x]` 双状态、GFM 表格(含对齐列)、TypeScript 代码块语言与原文、图片相对路径、H6、Mermaid 代码块源码 —— 同上。
|
||||
3. ✅ 纯中文无格式段落往返稳定。
|
||||
|
||||
**editing.test.ts(编辑行为)**
|
||||
4. ✅ 撤销/重做:程序化插入 → Ctrl+Z 等价命令恢复原文 → 重做再次应用。
|
||||
5. ✅ 选区合法性:插入后选区落在文档模型合法区间,文档可继续序列化。
|
||||
6. ✅ 编辑器 DOM 为真实 `contenteditable`(ProseMirror),非 textarea、非静态 HTML。
|
||||
|
||||
**一次性探针(已跑,结论登记,不留常驻用例)**
|
||||
- 脚注 `[^1]`:定义与引用完整往返 ✅(超出官方预设宣传范围,阶段 3 转正前复测)
|
||||
- 行内 HTML `<u>` 与块级 HTML `<div>`:原文保留 ✅
|
||||
- 引用内嵌列表、三级嵌套列表:结构保留 ✅
|
||||
- 性能探针:158KB 文档 jsdom 全量加载 ≈3.6s、序列化 ≈0.27s —— jsdom 无布局引擎、显著慢于 WebView2,仅作悲观下界;产品指标阶段 6 在真机重测(需求 §20)。
|
||||
|
||||
## 3. 验证中发现并已处理的问题
|
||||
|
||||
1. **序列化符号规范化**:Milkdown 默认无序列表符号为 `*`,已设 `remarkStringifyOptionsCtx: { bullet: '-' }` 对齐 Typora 习惯;表格对齐冒号仍会规范化(` :--- ` → ` :- `),语义无损、次轮稳定。已登记为选型报告已知限制 1。
|
||||
2. **React StrictMode 双重挂载**会重复创建 Milkdown 编辑器,原型已移除 StrictMode 并注释原因;正式版在编辑器组件内做幂等挂载处理。
|
||||
|
||||
## 4. 必须由大王在 Windows 人工验证的清单(沙箱无法替代)
|
||||
|
||||
阶段 0 结论为「有条件通过」,条件即以下四项在原型页面(`npm run dev` 后浏览器打开,或阶段 1 的 Tauri 壳内)人工确认:
|
||||
|
||||
- [ ] **中文 IME**:微软拼音连续输入中文长句,组合区不提前上屏、不触发 `#`/列表等自动转换;搜狗拼音同验一轮。
|
||||
- [ ] **即时渲染手感**:键入 `# `、`- [ ] `、`> `、表格语法,转换即时、无闪烁、光标不跳位。
|
||||
- [ ] **表格内编辑**:单元格内增删文字、Tab 跳格、末格 Tab 新增行(末格新增行为阶段 3 完整实现,原型先验基础编辑与跳格)。
|
||||
- [ ] **大段粘贴**:从网页/Word 粘贴富文本,格式映射合理、无整篇重排卡顿。
|
||||
|
||||
## 5. 阶段 0 交付物对照(需求 §23)
|
||||
|
||||
| 交付 | 状态 |
|
||||
|---|---|
|
||||
| 技术验证 Demo | ✅ `prototype/`,可 `npm run dev` 运行 |
|
||||
| 技术选型报告 | ✅ `docs/01-tech-selection.md` |
|
||||
| 核心交互原型 | ✅ 原型含标题栏/菜单/侧边大纲/编辑区/序列化侧栏/状态栏 + `docs/04-ui-prototype.md` |
|
||||
| 项目架构设计文档 | ✅ `docs/02-architecture.md` |
|
||||
| 核心编辑体验实际操作测试 | ⚠️ 自动化部分全绿;真机手感部分待 §4 人工清单(需大王 Windows 环境) |
|
||||
|
||||
## 6. 未完成事项与下一阶段
|
||||
|
||||
- Tauri 壳、窗口/菜单/标签页正式实现 → 阶段 1(前置:Windows Rust 工具链)。
|
||||
- 数学/Mermaid/代码高亮渲染、表格浮动工具栏、图片节点 → 阶段 3(原始文本保真已在阶段 0 锁定)。
|
||||
- 源码模式(CodeMirror 6)与标记显隐装饰 → 阶段 2。
|
||||
- **下一阶段计划(阶段 1)**:Tauri 2 工程初始化、自定义标题栏、菜单栏骨架(仅真实可用项)、标签页、侧边栏容器、编辑器挂载、状态栏、基础明暗主题;验收:可启动、界面完整、打开/编辑/保存一个 .md 全链路走通。
|
||||
45
docs/04-ui-prototype.md
Normal file
45
docs/04-ui-prototype.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 阶段 0 · UI 与交互原型说明
|
||||
|
||||
> 主界面定稿以需求 §3 为准。本文件说明:整体画面结构、操作逻辑、阶段 0 原型的对应关系。
|
||||
|
||||
## 1. 画面结构(自上而下五段)
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 标题栏 图标 · 文档名(未保存圆点) — □ × │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 菜单栏 文件 编辑 段落 格式 视图 主题 帮助 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 标签栏 文档A × │ 文档B ●× │ + │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ 侧边栏 │ │
|
||||
│ [文件|大纲] │ 编辑区(正文限宽居中) │
|
||||
│ 树 / 标题树 │ 标题 · 正文 · 代码块 · 表格 │
|
||||
│ │ │
|
||||
├──────────────┴───────────────────────────────────────────────┤
|
||||
│ 状态栏 字数 字符 行/列 UTF-8 换行 保存状态 模式 缩放 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
视觉定调(与大王既定品味一致):中性灰白、低饱和石板色点缀、适度圆角(6/8px)、无投影堆叠、无渐变、无卡片墙;深色为深灰非纯黑;正文字体与行距优先,界面元素全部退后。
|
||||
|
||||
## 2. 操作逻辑要点
|
||||
|
||||
- **单一编辑区**:默认即时渲染,不出现左右分栏预览;源码模式是整区切换(Ctrl 切换后光标/选区尽量保持),不是第二块常驻区域。
|
||||
- **侧边栏**:文件/大纲双页签,`Ctrl+Shift+L` 开合,宽度可拖、状态记忆;关闭后编辑区扩展但正文宽度不变(只改边距,不改阅读版心)。
|
||||
- **大纲**:标题树实时跟随文档模型;点击定位、当前标题高亮、拖拽排序直接改文档结构(走编辑器事务,可撤销)。
|
||||
- **标签页**:未保存显示圆点;关未保存标签必弹保存确认;右键菜单含关闭其他/右侧/全部。
|
||||
- **表格**:光标进入表格时上方浮现轻量工具栏(增删行列/对齐/删表),离开即消失,不占版面。
|
||||
- **图片**:选中浮现操作条(缩放保比例/对齐/替换/删除);粘贴图片自动落盘到资源目录并插入相对路径。
|
||||
- **专注/打字机/全屏**:F8/F9/F11;三者可叠加;全屏下鼠标触顶临时呼出菜单。
|
||||
- **状态栏即信息源**:字数(中文按字、英文按词,代码块计入可配置)、光标行列、编码、换行符、保存状态、当前模式,一眼确认文档状态,不另设信息卡片。
|
||||
|
||||
## 3. 阶段 0 原型对应关系(prototype/)
|
||||
|
||||
原型已落地的结构:标题栏占位行、菜单行(仅原型内真实动作:切换示例、撤销/重做、源码模式切换、侧边栏开关、序列化面板开关、导出 md)、侧边大纲(实时生成、点击定位)、中央编辑区(限宽 860px 居中)、右侧序列化面板(验证用,正式版无此面板)、底部状态栏(字数/字符/行数/编码/模式)。
|
||||
|
||||
原型刻意未做(避免假功能):真实窗口控制、文件打开/保存对话框、文件树、主题切换——均为阶段 1/4/5 内容,原型菜单不放占位按钮。
|
||||
|
||||
## 4. 交互验收口径
|
||||
|
||||
阶段 2 起每个编辑特性按「输入 → 即时呈现 → 光标落点 → 撤销一步回退 → 保存重开一致」五点验收;阶段 0 的人工清单见 03 号报告 §4。
|
||||
59
docs/05-stage1-report.md
Normal file
59
docs/05-stage1-report.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 阶段 1 · 基础桌面应用报告(实测记录)
|
||||
|
||||
> 日期:2026-11-13 · 范围:Tauri 工程、主窗口、标题栏、菜单栏、标签页、侧边栏、编辑器容器、状态栏、基础主题 + 打开/编辑/保存全链路。
|
||||
|
||||
## 1. 本阶段实现内容
|
||||
|
||||
- **正式工程**:仓库根目录建工程(`src/` + `src-tauri/`,结构按架构 §6);阶段 0 的 `prototype/` 保留为验证资产,其测试被根工程继续引用执行。
|
||||
- **Tauri 2 壳**:`decorations: false` 自定义标题栏窗口(1280×820,最小 920×600,居中);identifier `app.newmarkdown.desktop`;打包目标 nsis/msi;图标全套新生成(石板底文档 + M/↓ 字形,未用任何现成品牌图)。
|
||||
- **Rust 命令层**(`src-tauri/src/commands.rs`):`read_text_file`(UTF-8/BOM/UTF-16/GBK 探测、换行符识别)、`write_text_file`(同目录临时文件 + sync + 原子替换,可保留 BOM)、`list_dir`(文件夹优先排序、只收 md/markdown/txt、跳隐藏项)。前端一切磁盘访问只走 `services/platform.ts`。
|
||||
- **标题栏**:图标 + 文档名 + 未保存圆点 + 窗口控制(最小化/最大化还原/关闭,走 Tauri window API,能力已在 capabilities 授权)。
|
||||
- **菜单栏**:文件/编辑/段落/格式/视图/帮助六组,**全部条目为真实动作**。段落与格式直接走 Milkdown 命令(一命令一步撤销);文件组含最近文件(localStorage,阶段 4 迁 SQLite);编辑组粘贴用 execCommand(WebView2 支持,阶段 2 评估换 Clipboard API 时复核)。
|
||||
- **标签页**:新建/打开/激活/关闭、拖拽排序、右键菜单(关闭/关闭其他/关闭右侧)、未保存圆点、关脏标签弹三选确认(保存/不保存/取消,批量关闭同理)。会话模型为纯 reducer(`stores/tabs.ts`),同路径重复打开只激活不重复建签,dirty 由正文与已保存正文比较得出。
|
||||
- **侧边栏**:文件/大纲双页签。大纲实时提取(跳过代码块内 `#`)、点击定位(渲染模式滚动到标题、源码模式移动光标);文件树懒加载逐层展开、刷新、当前文件高亮。侧边栏开关与宽度、主题偏好持久化(localStorage)。
|
||||
- **状态栏**:字数(中文按字/英文按词)/字符/光标行列(由 ProseMirror 选区推导)/总行数/编码/换行符/保存状态/编辑模式/缩放/运行模式。
|
||||
- **编辑器容器**:阶段 0 工厂产品化(`src/editor/`),切标签卸载前回写序列化文本不丢内容;源码模式整区切换、切回重建模型;缩放 70%–140%。
|
||||
- **基础主题**:浅色/深色双主题(CSS 变量,深色为深灰非纯黑),视图菜单切换。
|
||||
- **Web 降级模式**:浏览器打开时打开文件走 file input、保存走下载,状态栏明示「Web 降级模式(调试)」,窗口控制与打开文件夹自动隐藏/禁用,不冒充桌面能力。
|
||||
|
||||
## 2. 构建与测试结果(实跑)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `npm install`(根工程) | ✅ |
|
||||
| 类型检查 `tsc -b` | ✅ 0 错误(过程修 1 处:commandsCtx.call 的 CmdKey 泛型擦除) |
|
||||
| 自动化测试 `vitest run` | ✅ **18/18**(新增 tabs 4 + text 6 + 编辑器回归 2,阶段 0 原型套件 6 一并重跑全绿) |
|
||||
| 生产构建 `npm run build` | ✅(JS 大包警告仍在,阶段 6 分包) |
|
||||
| Vite dev 冒烟 | ✅ HTTP 200,页面与模块正常下发 |
|
||||
| **Rust 逻辑实测** | ✅ 把正式 `commands.rs`(仅剥 tauri 宏行)编入独立校验 crate 实跑断言:BOM/CRLF 识别、GBK 解码、原子写覆盖 + BOM 保留、临时文件不残留、list_dir 过滤与排序——**全部通过** |
|
||||
| `tauri info` | ✅ 配置解析通过,crate 解析到 tauri 2.12.5;仅报 Linux webkit 系统库缺失(见已知问题) |
|
||||
| Tauri 完整构建 | ⚠️ 未在本沙箱完成:沙箱镜像把 libzstd1 钉在定制版上,apt 装不了 GTK/WebKit 开发包(链断在系统层,非工程问题)。Windows 上按官方前置(Rust + VS Build Tools + WebView2)执行 `npm run tauri dev` 即首次真机构建,已列入验收清单。 |
|
||||
|
||||
## 3. 已知问题与取舍
|
||||
|
||||
1. **Ctrl+0 双义**:需求表把 Ctrl+0 同时给「段落」与「恢复默认缩放」。本实现取「段落」(与 Typora 一致),恢复缩放走视图菜单,快捷键弹窗内有说明。阶段 6 自定义快捷键时可再改派。
|
||||
2. **最近文件/界面偏好暂存 localStorage**:SQLite 按架构排在阶段 4(文件管理阶段)统一落库,本阶段不提前引入数据库。
|
||||
3. **粘贴走 execCommand('paste')**:在部分 WebView2 权限策略下可能被拒;若 Windows 实测粘贴菜单不可用,阶段 2 改走 Milkdown clipboard 插件 + Tauri clipboard 权限处理(编辑器内 Ctrl+V 粘贴不受此影响,走浏览器原生粘贴事件)。
|
||||
4. 阶段 0 人工清单(IME 手感等)仍未销账,与本阶段 Windows 清单合并验证最省事。
|
||||
|
||||
## 4. Windows 验收清单(大王)
|
||||
|
||||
前置:装 Rust stable(rustup)、Visual Studio Build Tools(C++ 桌面开发)、WebView2(Win11 自带)。
|
||||
|
||||
```powershell
|
||||
cd new-markdown
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
- [ ] 窗口启动,自定义标题栏可拖动、双击最大化、三个窗口按钮可用
|
||||
- [ ] 打开一个真实 .md(含 GBK 编码的老文件各一),内容正确、状态栏编码/换行符正确
|
||||
- [ ] 改两笔 → 标题栏与标签出现未保存圆点 → Ctrl+S 保存 → 圆点消失,用外部编辑器确认落盘内容
|
||||
- [ ] 打开文件夹 → 文件树展开、点开第二个文件成新标签;拖拽标签排序;右键关闭其他
|
||||
- [ ] 关一个未保存标签 → 弹保存确认,三选都试一遍
|
||||
- [ ] 视图:深色主题、源码模式切回不丢内容、Ctrl+= 放大
|
||||
- [ ] 顺带把阶段 0 的 IME 清单一并验了(微软拼音 + 搜狗各一段长句)
|
||||
|
||||
## 5. 下一阶段(阶段 2)预告
|
||||
|
||||
Markdown 编辑器核心深化:标记显隐装饰(当前节点显示语法标记)、源码模式换 CodeMirror 6 并做双向选区同步、列表/引用/代码块的完整键盘行为、IME 守卫与专项测试、把阶段 0/1 人工清单中发现的问题清零。
|
||||
36
docs/06-stage2-report.md
Normal file
36
docs/06-stage2-report.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 阶段 2 · Markdown 编辑器核心报告(实测记录)
|
||||
|
||||
> 日期:2026-11-13 · 范围(需求 §23 阶段 2):即时渲染深化、标题/段落/格式/引用/列表/任务列表/代码块、撤销重做、源码模式、IME 兼容。
|
||||
|
||||
## 1. 本阶段实现内容
|
||||
|
||||
- **语法标记显隐**(`src/editor/plugins.ts`,自研 ProseMirror 插件):光标所在块显示 Markdown 标记、其余块只显排版结果——标题显示 `#` 前缀、引用段显示 `> `、行内标记(`**`、`*`、`` ` ``、`~~`、链接的 `[` 与 `](href)`)在光标进入对应文本段时在段两端显形。纯 Decoration 实现:不进文档模型、不进撤销栈、序列化零影响(测试硬断言)。IME 组合期间冻结装饰重算,组合结束补算一次。
|
||||
- **任务列表可点击勾选**(`src/editor/nodeViews.ts`):验证中发现 Kit 的 preset-gfm 只渲染 `<li data-checked>`、**没有真实复选框**(可点击勾选是 Crepe 组件层能力,选型时未覆盖到此细节)。已给 `list_item` 注册自有 NodeView:任务项渲染真实 checkbox,点击以一条事务切换 `checked`(可撤销),序列化自动回写 `- [ ]`/`- [x]`;普通列表项外观不变;完成项文字变灰。
|
||||
- **代码块键盘行为**:Tab 在代码块内插入两空格、Shift+Tab 反缩进(吞掉无缩进时的 Tab 防焦点跳走);其余位置 Tab 不变。Enter 换行、粘贴代码走既有 Milkdown 行为。
|
||||
- **列表/结构命令回归**:段落转无序列表、列表项升降级等命令与阶段 1 菜单共用同一命令层,本阶段补齐自动化断言。
|
||||
- **源码模式升级**(`src/components/SourceEditor.tsx`):textarea 换 **CodeMirror 6**——行号、Markdown 语法高亮(含代码块内嵌语言)、独立历史栈、括号匹配、Tab 缩进;深色跟随应用主题(oneDark)。组件懒加载,不进首屏主包。
|
||||
- **双向光标锚点同步**(`src/editor/cursorSync.ts`):以「当前文本块纯文本 + 块内偏移」为锚,渲染→源码按行匹配定位、源码→渲染按文本块定位;重复文本取首个匹配,找不到回落开头,不丢内容。纯函数单测覆盖标题/任务/引用标记的剥离与往返一致性。
|
||||
- **IME 守卫**:全局快捷键入口统一 `isImeComposing` 判定(isComposing 或 keyCode 229 直接放行);标记插件组合期冻结(见上)。真实输入法手感仍在 Windows 人工清单内(与阶段 0/1 清单合并)。
|
||||
- **菜单粘贴修复**:改走 Clipboard API 读纯文本后以事务插入选区,失败才退回 execCommand;源码模式聚焦 CodeMirror 走原生粘贴。
|
||||
|
||||
## 2. 构建与测试结果(实跑)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 类型检查 `tsc -b` | ✅ 0 错误 |
|
||||
| 自动化测试 `vitest run` | ✅ **31/31**(8 文件;本阶段新增 13:stage2 8 + cursorSync 4 + SourceEditor 冒烟 1) |
|
||||
| 生产构建 | ✅ 主包 646KB / gzip 202KB(SourceEditor 懒加载分包 539KB,语言高亮小块按需) |
|
||||
| 阶段 0/1 套件 | ✅ 全部继续绿(往返稳定、撤销、tabs、text 等 18 项) |
|
||||
|
||||
新增用例要点:标记显隐三态(标题显形 → 粗体显形且标题标记消失 → 移开全隐)+ 序列化与原文逐字一致;引用 `> ` 显形;代码块 Tab 进/退;任务 checkbox 渲染 2 个且第二项 checked、**点击第一项后序列化变 `- [x]`**;段落转列表命令;纯文本插入;IME 判定;锚点往返;CodeMirror 挂载与锚点/跳行 API。
|
||||
|
||||
## 3. 已知问题与边界
|
||||
|
||||
1. **标记显隐覆盖范围**:当前覆盖标题、引用、五种行内标记。列表标记(`-`/`1.`)由浏览器列表样式天然呈现,不做显隐;链接显形为 `[文案](href)` 近似形态。嵌套格式(如粗体内斜体)按段逐标记显形,顺序为 schema 顺序,真机观感待验。
|
||||
2. **锚点同步是近似定位**:锚点文本在全文重复出现时取第一个匹配,可能定位到同文段落而非原段落(内容零丢失)。精确版需给块级节点加稳定 id,排阶段 6 体验优化评估。
|
||||
3. **SourceEditor 在 jsdom 仅冒烟**(挂载/API),真实滚动、中文输入与高亮观感随 Windows 清单验。
|
||||
4. 阶段 0/1 的 Windows 清单仍未销账(含 IME 真机四项)——本阶段没有新增必须真机的前置,但建议合并一次验完再进阶段 3。
|
||||
|
||||
## 4. 下一阶段(阶段 3)预告
|
||||
|
||||
复杂内容编辑:表格(浮动工具栏、增删行列、Tab 跳格/末格加行)、图片(插入/粘贴落盘/浮动操作条)、超链接编辑、数学公式(KaTeX 节点)、Mermaid(渲染节点 + 源码编辑)、脚注转正、HTML 块编辑、附件链接。验收口径不变:先给往返套件加用例再实现。
|
||||
40
docs/07-stage3-report.md
Normal file
40
docs/07-stage3-report.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 阶段 3 · 复杂内容编辑报告(实测记录)
|
||||
|
||||
> 日期:2026-11-13 · 范围(需求 §23 阶段 3):表格、图片、超链接、数学公式、Mermaid、脚注、HTML、附件。
|
||||
|
||||
## 1. 本阶段实现内容
|
||||
|
||||
- **表格全套**:
|
||||
- 插入:段落菜单/Ctrl+T 插入 3×3(gfm `insertTableCommand`)。
|
||||
- 浮动工具栏(`TableToolbar.tsx`):选区进入表格时出现在表格上方,滚动时跟随;含 加行/加列/删行/删列/左齐/居中/右齐/删表(删类走 prosemirror-tables 命令、增类与对齐走 gfm 命令)。
|
||||
- **整列对齐修正**:GFM 对齐由表头行定义、插件的 `setAlignCommand` 只改选区所在单元格——直接用会在数据格上点了没反应。已封装 `setAlignColumn`:自动定位同列表头格执行再恢复选区,用户在任意格点对齐都生效。
|
||||
- Tab 导航:自研插件——表内 Tab 跳下一格、Shift+Tab 回上一格、**末格 Tab 自动新增一行再跳入**;IME 组合期不拦截。
|
||||
- **图片与附件落盘**(Rust 新增 `copy_to_assets`、`save_bytes_to_assets`,逻辑已在独立 crate 实跑断言):
|
||||
- 资源进 `<文档主名>.assets/` 目录,相对路径写回 Markdown;文件名清洗 Windows 非法字符、重名自动 `-1` 递增。
|
||||
- 插入图片(Ctrl+Shift+I):文件选择 → 复制落盘 → 相对路径插入;未保存文档先提示保存。
|
||||
- 粘贴/拖入图片:upload 插件接管,已保存文档落盘回相对路径,未命名/Web 模式退为 data URL。
|
||||
- 附件:任意文件复制落盘后以 `[文件名](相对路径)` 插入。插入带链接文本踩到 ProseMirror 的标记归一化坑(直接插带 mark 的文本节点会丢标记),改走 storedMarks + insertText,已有断言把住。
|
||||
- **数学公式**:接入官方 `@milkdown/plugin-math` 7.5.9(KaTeX 0.16,许可证 MIT;与内核 7.23 的 peer 范围兼容)——行内 `$…$`、块级 `$$…$$` 解析为公式节点渲染并无损往返;段落菜单可插入公式块(该节点是 atom,LaTeX 存 `attrs.value`,实现按此处理)。KaTeX CSS 进主包。
|
||||
- **Mermaid**:code_block 自有节点视图——language=mermaid 时渲染 SVG 预览(mermaid 12 懒加载分包,不进首屏),头部可切换「编辑源码/查看预览」,编辑后防抖重渲染,渲染失败显示错误信息并保留源码入口;普通代码块维持标准 pre>code。段落菜单可直接插入 Mermaid 示例块。
|
||||
- **脚注/HTML 转正**:阶段 0 的一次性探针改为常驻回归用例(脚注定义/引用、行内 `<u>`、块级 `<div>` 往返稳定)。
|
||||
|
||||
## 2. 构建与测试结果(实跑)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 类型检查 `tsc -b` | ✅ 0 错误 |
|
||||
| 自动化测试 | ✅ **45/45**(9 文件;本阶段新增 14:表格 4、数学 3、Mermaid/代码块 3、脚注/HTML 2、图片/附件 2) |
|
||||
| Rust 校验 crate | ✅ 新增两命令断言全过(落盘路径、重名去重、字节写入回读) |
|
||||
| 生产构建 | ✅ 主包 930KB / gzip 288KB(KaTeX 进主包 +约 280KB;Mermaid 核心 656KB 与 elk 布局 1.5MB 均为懒加载块) |
|
||||
| dev 冒烟 | ✅ HTTP 200 |
|
||||
|
||||
## 3. 已知问题与边界
|
||||
|
||||
1. **Mermaid 渲染在 jsdom 无法真验**(缺 SVG 布局 API):测试只断言节点视图容器与源码保真,SVG 真渲染随 Windows 清单验;已做失败兜底(显示错误原因不白屏)。
|
||||
2. 表格选区拖拽选多格、行列拖动排序未做(插件有 moveRow/moveCol 命令,排阶段 6 体验项);表格工具栏位置在极端滚动下为选区变化时快照 + 滚动跟随,非逐帧跟踪。
|
||||
3. 图片暂无缩放/对齐浮动操作条(schema 无宽度属性,扩展需改 schema 与序列化,排阶段 6 评估);本地图片在 WebView 中的显示依赖相对路径解析,真机验。
|
||||
4. plugin-math 版本线停在 7.5.9(官方现状),已锁版本;若后续 Milkdown 8 升级需一并评估。
|
||||
|
||||
## 4. 下一阶段(阶段 4)预告
|
||||
|
||||
文件与工作区深化:Rust/SQLite 落库(最近文件、工作区、偏好从 localStorage 迁入)、自动保存与崩溃恢复、文件恢复记录、未保存提醒完善、外部修改检测与冲突处理、文件夹工作区增强(新建/重命名/删除文档、拖拽组织)。
|
||||
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 },
|
||||
})
|
||||
31
samples/complex.md
Normal file
31
samples/complex.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 复杂文档验证
|
||||
|
||||
## 任务列表
|
||||
|
||||
- [ ] 待完成事项
|
||||
- [x] 已完成事项
|
||||
|
||||
## 表格
|
||||
|
||||
| 名称 | 类型 | 数量 |
|
||||
| :--- | :---: | ---: |
|
||||
| 苹果 | 水果 | 3 |
|
||||
| 香蕉 | 水果 | 5 |
|
||||
|
||||
## 代码块
|
||||
|
||||
```typescript
|
||||
const greeting: string = "你好,世界";
|
||||
console.log(greeting);
|
||||
```
|
||||
|
||||
行内公式原始文本:$E = mc^2$ 应被完整保留。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[开始] --> B[结束]
|
||||
```
|
||||
|
||||

|
||||
|
||||
###### 六级标题
|
||||
Reference in New Issue
Block a user