From 9b0aff6f9f84fe3578fbad0557cc34b598ce6d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Fri, 11 Sep 2026 01:02:36 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8D=B3=E6=97=B6=E6=B8=B2=E6=9F=93=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E3=80=81=E5=AE=9E=E6=96=BD=E4=B8=8E=20P3=20=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editor-live-rendering.md(新):Typora 式单编辑面方案——路线选型、装饰层分层设计、 widget 规格、安全与风险、测试方案、P0–P3 实施拆解;§12 为实施与加固记录: 两个实测缺陷的根因与修复、3000 行性能实测数据、§12.6 真机输入法人工门槛(阻塞发布)、 §12.7 编辑区高度 - design.md §8.2:编辑器段落由「分屏实时预览」改为即时渲染表述,链接本方案并标注发布门槛 - decisions.md:D37–D52(装饰器路线、单一 ViewPlugin、行级 reveal、长文档补解析、 选区端点行采集、reveal 二分、@codemirror/commands 仅为 devDependency、人工门槛、编辑区高度) - acceptance.md:新增 M5 里程碑与 M5-G 人工门槛行,补 v1.1/v1.2 浏览器复走记录与用例数 文档先行,代码实现见后续提交。 --- docs/acceptance.md | 36 ++- docs/decisions.md | 21 ++ docs/design.md | 12 +- docs/editor-live-rendering.md | 586 ++++++++++++++++++++++++++++++++++ 4 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 docs/editor-live-rendering.md diff --git a/docs/acceptance.md b/docs/acceptance.md index b377e26..c568710 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -43,6 +43,7 @@ | slug | 中文 post-YYYYMMDD、同日冲突 -2、回收站占用、手改 409 字段级错误、非法格式 400 | 同上 | | 元信息注入 | 公开注入笔记 meta;私有/不存在回退站点默认;html/template 转义 | visibility_test.go + webui_test.go | | 前端 vitest | sanitize schema 快照 + 恶意 Markdown 冒烟 + GFM 回归 + node prop 泄漏检查 | web/tests/markdown.test.tsx(6/6) | +| 即时渲染装饰层 | 语法区间识别 / reveal(含二分等价)/ 安全 / 文首文末边界 / 跨原子区间选区 / 不变量 / 3000 行性能基准 | web/tests/editor-live-preview.test.ts(29)+ editor-boundaries.test.ts(32) | **回归命令**:`make test`(go vet + go test ./... + vitest)与 `make smoke`。 @@ -55,6 +56,8 @@ | M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ | | M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ | | M4 运维 | systemd ×3 + Caddyfile + 备份恢复演练真还原 | ✅(压测 hey 未做:单机个人规模非验收必需,工具未安装;`SetMaxOpenConns(1)` 串行模型按设计实现) | +| M5 编辑器即时渲染(P0–P3) | 编辑态=阅读态排版(`--md-*` 契约)、全语法即时渲染、光标行展开/失焦整篇、任务框回写源文本、源码模式逃生舱;边界与选区回归(`web/tests/editor-boundaries.test.ts` 32 条);3000 行性能基准达成 §7.4 的 <16ms 目标 | ✅ 代码与自动化(实测数据见 [editor-live-rendering.md](editor-live-rendering.md) §12.5) | +| **M5-G 人工门槛:真机输入法** | 系统拼音 / 搜狗拼音按 §12.6 的 I1–I8 用例走查并签字 | ⛔ **未执行 —— 阻塞发布** | ## 五、真实浏览器走查(补充验收) @@ -64,23 +67,50 @@ 2. **详情页**:面包屑、标题/日期/标签、GFM 表格与任务列表(disabled checkbox)渲染、上一篇/下一篇; 3. **登录**:错误口令提示、成功跳转 /admin; 4. **管理列表**:公/私徽标、slug/时间/标签、状态切换/编辑/删除; -5. **编辑器**:CodeMirror 输入、分屏实时预览、保存后自动生成 slug("go")、摘要自动截取、URL 迁移到 /notes/3/edit; +5. **编辑器**:CodeMirror 输入、~~分屏实时预览~~(v1.1 起改为**即时渲染**,方案见 [editor-live-rendering.md](editor-live-rendering.md);源码模式为逃生舱)、保存后自动生成 slug("go")、摘要自动截取、URL 迁移到 /notes/3/edit; 6. **回收站**:删除确认弹窗 → 列表移除 → 回收站出现 → 恢复 → 公开页重新可见(标签页标题即 meta 注入验证)。 +### v1.1 编辑器即时渲染复走(2026-09-11) + +在 `pn start --dev` + `vite dev` 上对内联渲染逐项复走:标题/粗体/斜体/删除线/行内代码/链接/嵌套引用/ +三级无序圆点/有序序号保留/任务框/图片(原尺寸+圆角)/水平线/围栏语言标签与底色/表格等宽淡化均按预期; +光标行展开源码、失焦整篇渲染;点击任务框改写源文本并自动保存;源码模式反复切换无残留。 +细节与剩余人工项见 [editor-live-rendering.md](editor-live-rendering.md) §12。 + +### v1.2 P3 加固复走(2026-09-11) + +- **边界**:文档以标题/任务项/围栏/水平线开头、以水平线/图片/围栏/任务结尾均正确渲染;整篇只有一条水平线可用; + 文首连续退格不损坏内容;文末追加内容后装饰正确重建。 +- **跨原子区间选区**:3000 行文档 `Cmd+A` + 退格清空、`Cmd+Z` 完整恢复;跨隐藏标记的选区替换不残留标记碎片; + 只选中可见内容时标记完整保留。 +- **性能**:3000 行 / 15.6 万字符文档,视口装饰采集中位数 0.26ms、全篇最坏 5.0ms、最坏选区(Cmd+A)0.98ms、 + 真实输入同步耗时中位数 0.1ms、冷启动直跳文末最大帧间隔 7.1ms 且零 long task(详见方案 §12.5.2)。 +- **修复**:长文档惰性解析导致未解析区间显示裸 Markdown(已修,方案 §12.5.1 P3-1); + `Cmd+A` 全文档装饰构建与线性 reveal 扫描(已修,P3-2)。 +- **编辑区高度**(2026-09-11,用户要求):编辑卡默认吃满视口剩余高度、长文改为编辑器内部滚动。 + 1280×720 下空文档与 3000 行长文的编辑卡均为 610px;长文页面保持一屏(此前 8.4 万像素), + `.cm-scroller` 内部可滚;窄屏 ≤900px 回落内容高度。方案 §12.7。 + +### ⛔ 未通过的人工门槛(阻塞发布) + +真机输入法回归(系统拼音 / 搜狗拼音)**尚未执行**,用例、环境要求与签字要求见 +[editor-live-rendering.md](editor-live-rendering.md) §12.6。**在签字通过前不得发布**。 + ## 六、交付物清单 ``` pn(单二进制,CGO_ENABLED=0,go:embed 前端产物) ├── cmd/pn/ start / init / passwd / backup / gc / version ├── internal/{config,store,auth,markdown,middleware,httpapi,webui} -├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例) +├── web/ React 19 + Vite 8 + Tailwind 4(vitest 71 用例)+ editor/ 即时渲染装饰层 ├── deploy/ pn.service、pn-maint.{service,timer}、Caddyfile ├── scripts/smoke.sh 构建冒烟(make smoke) ├── Makefile / README.md / .gitignore └── docs/{design.md, review-round1.md, decisions.md, acceptance.md} ``` -依赖面:Go 直接依赖 6 个(预算 ≤6);npm 生产依赖与设计一致,0 漏洞。 +依赖面:Go 直接依赖 6 个(预算 ≤6);npm 生产依赖与设计一致(运行时零新增),0 漏洞; +新增 `@codemirror/commands` 为 devDependency(本就在依赖树中,用于边界回归真实执行编辑命令)。 锁定文件 go.sum / package-lock.json 入库。 ## 七、遗留事项(不阻塞验收) diff --git a/docs/decisions.md b/docs/decisions.md index 5ee494b..766ed1e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -64,3 +64,24 @@ | D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 | | D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 | | D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 | + +## 6. 编辑器即时渲染改造(2026-09-11,方案 `docs/editor-live-rendering.md` v1.1) + +| # | 决策 | 依据 | +| --- | --- | --- | +| D37 | 采用 **CM6 装饰器即时渲染**(路线 A),不引入 ProseMirror/Tiptap 富文本模型;**零新增 npm 依赖** | 方案 §2.3 / E1;真相源保持 Markdown 原文,公开页渲染管线与 sanitize 防线完全不动 | +| D38 | 全部装饰由**单一 ViewPlugin** 产出,不引入 StateField | 方案 E10 / §12.2 D1:实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),免去全文档遍历与块 widget 布局/选区风险;代价是闭围栏行留下 1 行空白作为代码块底部内边距 | +| D39 | 模块合并 `markers.ts`/`inline.ts`/`blocks.ts` → `syntax.ts` + `decorations.ts` | 方案 §12.2 D2;三者职责同为「节点 → 装饰」,拆分只增加跳转成本;单测边界改为「识别」与「构建/展开」 | +| D40 | reveal 以**编辑器聚焦**为前提(失焦整篇渲染);粒度 v1 取**行级** | 方案 E11 / E6:避免载入即显示首行 `#`;行级对 IME 最安全,span 级列入 §10.2 | +| D41 | **图片与任务复选框不参与 reveal**:图片恒渲染(改 URL 走源码模式),复选框恒可点 | 方案 E12 / §12.2 D3-D4:与 Typora 一致;避免「段落内行内图片在编辑该行时永远显示源码」与「光标落到任务行后复选框变文本无法点击」 | +| D42 | 行号/活动行高亮用 CSS(`.pn-cm-live .cm-gutters{display:none}`)隐藏,不改 `basicSetup` | 方案 §12.2 D5:不依赖 `@uiw/react-codemirror` 对 `basicSetup` 的运行时重配置行为 | +| D43 | 排版参数抽为 `--md-*` CSS 变量,**阅读态 `.pn-note-body` 与编辑态 `.cm-md-*` 共同引用** | 方案 §4.6 / E9:编辑态视觉=阅读态视觉;实机核对公开页计算样式未变(15px/27px、标题衬线、段落 24px) | +| D44 | 表格 v1 只做**源码等宽对齐 + 竖线淡化**(普通 55%、分隔行 38% 透明),不渲染 HTML | 方案 E5 / §10.1;`TableDelimiter` 在 lezer 中对分隔行是整段节点,故用单个带 `delimiter` 标记的 spec 表达 | +| D45 | `AdminEdit`「预览」按钮改为**「源码模式 / 退出源码」**,删除分屏预览块、`MarkdownViewer` import 与 `.pn-ae-preview` 样式 | 方案 §4.7:逃生舱;CM 体积仍走 lazy chunk,`MarkdownViewer` 继续服务公开页 | +| D46 | 真机输入法回归**列为发布前硬门槛未执行**;自动化仅覆盖合成组合事件(`compositionstart/end`)与 `insertText` 中文输入 | 方案 §7.1 / §12.4:自动化测不出真实 IME 行为,冻结逻辑(`view.composing` 时只映射不重建)已实现但需人工真机确认 | +| D47 | 长文档必须**补解析**:装饰前用 `ensureSyntaxTree(state, 可见区间末端, 20ms)`,并在 ViewPlugin 增加「语法树对象换代 → 重建」触发 | P3 实测 `syntaxTree(state)` 对 3000 行/15.5 万字符文档首次只覆盖 3061 字符(lezer 惰性增量),不补解析则未解析区间显示裸 Markdown;后台解析推进的事务没有 doc/selection 变化,必须靠树对象换代触发重算。20ms 预算有界(整篇解析实测仅 15.4ms,未真正耗尽) | +| D48 | 装饰采集范围 = `visibleRanges` + 选区**端点**行,而非选区跨越的全部行 | CM 只把选区端点所在的块渲染到视口之外(其余用 `cm-gap` 占位);全量取会让 `Cmd+A` 在长文档上退化为全文档装饰构建。仅取端点行即可让「凡渲染出来的行都有装饰」成立 | +| D49 | `applyReveal` 改为「区间合并 + 二分」,替代线性扫描 | 整篇选区在 3000 行文档下产生 3000 个展开区间,原 O(装饰数×区间数) 最坏约数千万次比较;二分后最坏选区(Cmd+A)构建中位数 0.98ms。另加「二分/线性结果等价」单测守护 | +| D50 | 新增 `@codemirror/commands` 为 **devDependency**(运行时零新增依赖不变) | 边界回归需要真实执行 `deleteCharBackward` 等编辑命令来验证文首退格/原子区间行为;该包本就在依赖树中(`@uiw/react-codemirror` 的依赖),不新增安装体积、不进入产物 | +| D51 | 真机输入法回归形式化为**人工验收门槛(阻塞发布)**,见方案 §12.6 与 `acceptance.md` M5 | §7.1 早已声明其为硬门槛;自动化(合成 composition 事件 + `insertText`)只能证明冻结逻辑不崩、文本不乱序,无法替代真实 IME。门槛含 8 项用例、覆盖系统拼音与搜狗、要求执行人与环境签字;当前状态:**未执行** | +| D52 | 编辑卡默认**吃满视口剩余高度**(`.pn-ae-grid: calc(100vh - 110px)`),长文改为编辑器内部滚动(`.pn-ae-body-wrap` 吃掉剩余高度,滚动由 `.cm-scroller` 承担) | 用户要求(2026-09-11)。原实现 `.pn-ae-body-wrap` 仅 `min-height: 420px` 且高度随内容撑开:空文档编辑区只有 420px,3000 行长文把页面撑到 8.4 万像素、只能整页滚动。实测:两种场景下编辑卡均 610px(1280×720),长文 `.cm-scroller` 内部滚动 85087px、页面保持一屏;≤900px 堆叠断点回落 `height: auto`。方案 §12.7 | diff --git a/docs/design.md b/docs/design.md index 55ae71d..787d43b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -104,7 +104,7 @@ | 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) | | 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook | | Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) | -| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) | +| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 即时渲染(Typora 式:隐藏语法标记 + 光标处展开源码,见 [editor-live-rendering.md](editor-live-rendering.md)),零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) | | 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) | | 图标 | lucide-react | 1.42.0 | 活跃、风格统一(antd 组件内亦复用) | — | @@ -401,7 +401,7 @@ SecurityHeaders(含 HSTS,§9.2) | `/admin/login` | 登录页 | 口令登录、防爆破提示 | | `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) | | `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 | -| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 | +| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 即时渲染(Typora 式,见 [editor-live-rendering.md](editor-live-rendering.md))、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 | | `*` | 404 | 统一兜底 | ### 8.2 核心技术要点 @@ -422,7 +422,13 @@ Markdown 原文 **编辑器(编辑态)** -- CodeMirror 6 + `@codemirror/lang-markdown`,`minimalSetup` + 行号 + 等宽字体;工具栏提供加粗/斜体/链接/代码/表格等 Markdown 辅助插入; +> 即时渲染(Typora 式)改造方案见 **[docs/editor-live-rendering.md](editor-live-rendering.md)**(v1.2,P0–P3 已实施并完成实机验证与性能实测);本段原「分屏实时预览」表述已被该方案取代,数据层与浏览态渲染管线不变。 +> **发布门槛**:该方案 §12.6 的真机输入法回归(系统拼音 / 搜狗)是**阻塞发布的人工验收项**,当前未执行。 + +- CodeMirror 6 + `@codemirror/lang-markdown`,**即时渲染**:语法标记默认隐藏、光标所在行展开为源码(`web/src/editor/` 装饰层); + 行号与活动行高亮仅在**源码模式**下显示,「源码模式」按钮即逃生舱(方案 §4.7); + 正文排版与公开页共用 `--md-*` 契约变量(`index.css`),编辑态视觉 = 阅读态视觉; + 工具栏提供加粗/斜体/链接/代码/表格等 Markdown 辅助插入; - **图片粘贴/拖拽**:自写 paste/drop handler(≤50 行)→ `POST /api/admin/images` → 光标处插入 `![描述](/api/images/{id})`; - **slug 策略**:首次保存时由标题自动生成——中文标题退化为 `post-`,与现有记录(含回收站)冲突时追加 `-2`/`-3` 直至唯一;**首次保存即定稿,此后除非用户手动修改否则永不变化**;用户手改与他人冲突 → 服务端 409 + 字段级错误提示,前端就地高亮; - 自动保存:防抖 2s;大改动前本地兜底(失败时保留编辑态不丢内容)。 diff --git a/docs/editor-live-rendering.md b/docs/editor-live-rendering.md new file mode 100644 index 0000000..6bcbc3f --- /dev/null +++ b/docs/editor-live-rendering.md @@ -0,0 +1,586 @@ +# Pure Note — Markdown 即时渲染(Typora 式)设计方案 + +> 版本:v1.2 +> 日期:2026-09-10(v1.0 方案)/ 2026-09-11(v1.1 实施、v1.2 P3 加固) +> 状态:**P0–P3 代码与自动化验证全部完成**(§12);唯一剩余项是 §12.6 的**人工输入法门槛(阻塞发布)** +> 关联:`docs/design.md` §8.2、§8.1;`docs/decisions.md` D5 / D26 / D37–D52 +> 定位:本文档**取代** `docs/design.md` §8.2「编辑器(编辑态)」中「分屏实时预览」的表述; +> 数据层、服务端渲染管线、安全防线均不在本次变更范围内。 + +--- + +## 1. 背景与目标 + +### 1.1 现状(改造前基线) + +改造前编辑体验是「一块源码编辑区 + 一块渲染预览区」,两者物理分离: + +| 层 | 位置 | 实现(改造前) | +| --- | --- | --- | +| 编辑态 | `web/src/components/Editor.tsx` | CodeMirror 6 源码 + 行号 + 等宽字体 | +| 预览触发 | `web/src/pages/AdminEdit.tsx` | `showPreview` 开关,`预览 / 隐藏预览` 按钮 + 条件渲染 | +| 浏览态 | `web/src/components/MarkdownViewer.tsx` | react-markdown → rehype-sanitize → rehype-highlight | +| 排版契约 | `web/src/index.css` | `.pn-note-body` 正文排版 | +| 真相源 | SQLite `notes.content` | Markdown **原文**,编辑与展示双向都不落地富文本中间态 | + +> 上表是改造前的基线,便于对照回归面;**改造后的实际落地见 §12**。 + +### 1.2 问题 + +1. 编辑区显示的是源码:`#`、`**`、`>`、`| --- |` 全部裸露,且用等宽字体,与阅读页视觉完全割裂; +2. 想确认效果必须手动切换/分屏,写作—核对反复来回,长文与表格尤其痛苦; +3. 分屏在窄屏下不可用(当前实现是「切换」而非真分屏),移动端等于没有预览。 + +### 1.3 目标 / 非目标 + +**目标(IN SCOPE)** + +- **单一编辑面**:输入即渲染,语法标记默认隐藏、按渲染态显示,光标进入处展开为源码可编辑(Typora 核心手感); +- **编辑态视觉 = 阅读态视觉**:编辑区与公开笔记页共用同一套排版契约; +- **数据层零改动**:真相源仍是 Markdown 原文,公开页渲染管线(react-markdown + sanitize + highlight)与全部安全防线不动; +- **一键源码模式**作为逃生舱,装饰层出问题时用户永远可退回可用状态。 + +**非目标(OUT OF SCOPE,后续方向见 §10)** + +- 表格的 HTML 渲染与可视化编辑; +- 数学公式(KaTeX/MathJax)与 Mermaid/流程图; +- 引入 ProseMirror / Tiptap / Milkdown 等富文本模型; +- 多光标协同、版本历史、移动端专门适配。 + +--- + +## 2. 调研:Typora 即时渲染的实现原理 + +### 2.1 Typora 的可观察实现(闭源) + +Typora 不公开源码,以下为官方文档 + DOM 逆向 + 用户报告可确认的事实。 + +**不是「文本区 + 预览区」,而是单一 contenteditable 编辑面**,整篇文档即一棵渲染后的 DOM 树。公开的 DOM dump([typora-issues#6629](https://github.com/typora/typora-issues/issues/6629))显示: + +- 根容器 `#write`;块级元素带 `mdtype`(`heading` / `plain` / `table` / `list-item` / `fences`…)与稳定内容 id `cid`; +- 行内片段带 `md-inline`(`plain` / `code`…);语法的成对定界符是独立 span(`md-pair-s`); +- `md-expand` 标记「该片段已展开为源码」的状态。 + +**三条核心机制**(官方 [Markdown Reference](https://support.typora.io/Markdown-Reference/)): + +1. **输入即渲染**:"Span elements will be parsed and rendered right after typing." +2. **光标进入即展开源码**:"Moving the cursor to the middle of a span element will expand that element into the Markdown source." —— 平时 `**粗体**` 显示为粗体,光标移入后 `**` 才现身可编辑; +3. **块级元素整体渲染**:代码围栏、表格、公式、图表、图片各有专门呈现;另有全局源码模式(Cmd/Ctrl+/)。 + +内部算法(AST ↔ DOM 映射、脏块增量重解析)无公开文档。但从用户报告看并非高度增量:>5000 行、>10000 词即明显卡顿([#6542](https://github.com/typora/typora-issues/issues/6542)、[#6551](https://github.com/typora/typora-issues/issues/6551))。**结论:即时渲染的交互模型不难复刻,难点在大文档性能、IME 与选区边界。** + +### 2.2 开源对照 + +| 编辑器 | 技术栈 | 真相源 | 保真度 | 备注 | +| --- | --- | --- | --- | --- | +| **Obsidian Live Preview** | CodeMirror 6(闭源) | Markdown 原文 | 逐字节 | 与本方案同路线,最接近的生产级范例 | +| **Vditor IR** | 自有 Lute 引擎 | Markdown 原文 | 逐字节 | 三模式 sv/wysiwyg/ir | +| **HyperMD** | CodeMirror 5 | Markdown 原文 | 逐字节 | 插件式隐藏,CM5 时代产物 | +| **Milkdown / Crepe** | ProseMirror + remark | ProseMirror 文档 | 序列化规范化 | markdown 仅为导入导出格式 | +| **Tiptap + `@tiptap/markdown`** | ProseMirror + MarkedJS | Tiptap JSON | 有损 | 官方标注 early release,往返缺陷多 | +| **Toast UI Editor** | ProseMirror | Markdown 字符串 API | 规范化 | 富模型驱动 | +| **ByteMD** | CodeMirror + remark | Markdown 原文 | 编辑侧精确 | 预览为单向 HTML | + +### 2.3 路线选型 + +| | 路线 A:CM6 装饰器即时渲染 | 路线 B:富文本模型 WYSIWYG | +| --- | --- | --- | +| 真相源 | Markdown 原文(不变) | ProseMirror/Tiptap JSON | +| 改动面 | 仅 `Editor.tsx` 内部 + 新增装饰模块 | 编辑器 + 序列化 + 预览管线全部替换 | +| 数据/安全 | 不动 | 需重新论证 | +| 保真度 | 原文逐字节不变 | 表格/转义/代码围栏/脚注有损 | +| 依赖 | **零新增**(已装 CM6 全部够用,见附录 A) | 新增 prosemirror 全家桶(bundle 显著增大) | +| 主要风险 | 光标/选区/IME 的边角情况 | 富模型 ↔ Markdown 往返缺陷 | + +**选路线 A。** 理由: + +1. 与项目定位一致 —— `design.md:118` 明确选 CM6 就是为了「不需要维护富文本中间态」;换富模型等于把这条好处还回去; +2. 命中既有预留 —— `design.md:684` 已声明「`MarkdownViewer` 组件边界独立;若日后要 WYSIWYG,仅替换 Editor,数据层不变」; +3. 零新增依赖、公开页渲染管线与安全防线完全不动,回归面最小; +4. 路线 B 的往返有损已被大量实证(Tiptap 转义块丢失 [#8134](https://github.com/ueberdosis/tiptap/issues/8134)、行内 code 含反引号被破坏 [#8298](https://github.com/ueberdosis/tiptap/issues/8298)、表格内转义竖线丢失 [#8294](https://github.com/ueberdosis/tiptap/issues/8294)),对「极简健壮、可预期」是倒退。 + +--- + +## 3. 总体设计 + +### 3.1 架构分层 + +``` +Editor.tsx(React 外壳,接口:value / onChange / onUploadStart / onUploadEnd / sourceMode) +│ 职责:拖拽/粘贴上传、主题、源码模式开关、拖拽遮罩 +└── web/src/editor/livePreview.ts 扩展集入口(ViewPlugin + atomicRanges + Compartment) + ├── syntax.ts 基于 lezer syntaxTree 识别语法区间 → 装饰描述 Spec(纯函数,可单测) + ├── reveal.ts 光标处展开(行粒度):装饰集是 selection 的函数(纯函数,可单测) + ├── decorations.ts Spec → DecorationSet / 原子区间;图片 src 白名单 + └── widgets.ts BulletWidget / QuoteBarWidget / HrWidget / ImageWidget / + FenceLangWidget / TaskWidget +``` + +> 实施时把方案里的 `markers.ts` / `inline.ts` / `blocks.ts` 合并为 `syntax.ts` + `decorations.ts` +> (三个文件职责都是「节点 → 装饰」,拆分只增加跳转成本),详见 §12.2 偏差 D2。 + +### 3.2 数据流(不变) + +``` +DB(notes.content = Markdown 原文) + ├── 编辑态:CodeMirror 文档 = 原文;装饰器只改「视图」,不碰文档 + └── 浏览态:MarkdownViewer = react-markdown 管线(原样保留) +``` + +**不变量:装饰层永不修改文档内容,只影响呈现。** `onChange` 拿到的一直是用户键入后的 Markdown 原文。 + +### 3.3 一致性契约 + +编辑态渲染结果必须与公开页一致,覆盖:字号/行高、标题层级字号与字体族(`--font-serif`)、引用左边框、代码块底色与等宽、列表间距、链接样式、图片圆角、表格边框。实现方式见 §4.6。 + +--- + +## 4. 详细设计 + +### 4.1 语法识别 + +**用 lezer 语法树,不自己写正则。** `syntaxTree(state)`(`@codemirror/language` 6.12.4)由 `@codemirror/lang-markdown` 6.5.2 增量维护;当前 `Editor.tsx:49` 配置为 `markdown({ base: markdownLanguage })`,`markdownLanguage` 为 GFM 语言,`Table` / `Task` / `Strikethrough` 节点可用(已核实)。 + +按节点取区间: + +| 语法 | lezer 节点 | 处理 | +| --- | --- | --- | +| 标题 | `ATXHeading1..6` + `HeaderMark` | 隐藏 `#`;行加 `.cm-md-h{1..6}` | +| 粗/斜/删除线 | `StrongEmphasis` / `Emphasis` / `Strikethrough` + `*Mark` | 隐藏定界符;内容加 mark | +| 行内代码 | `InlineCode` + `CodeMark` | 隐藏反引号;内容加 `.cm-md-code` | +| 链接 | `Link` + `LinkMark` | 隐藏 `[`、`](url)`;文本加 `.cm-md-link` | +| 图片 | `Image` | 整段替换为 `` widget(§5.4) | +| 引用 | `Blockquote` + `QuoteMark` | `>` → 竖线 widget(§5.3) | +| 列表 | `BulletList` / `OrderedList` + `ListMark` | 圆点 widget / 数字保留(§5.1、§5.2) | +| 代码围栏 | `FencedCode` + `CodeMark` / `CodeInfo` | 起围栏 → 语言标签 widget;闭围栏隐藏;块加 `.cm-md-fenced` | +| 水平线 | `HorizontalRule` | 整行替换为 `
` 块 widget | +| 任务项 | `Task` + `TaskMarker` | `[ ]`/`[x]` → 可点 checkbox widget(§5.4) | +| 表格 | `Table` 全族 | v1 仅行级等宽对齐 + 定界符淡化,**不渲染 HTML**(§10.1) | + +### 4.2 隐藏与原子化 + +- 纯定界符区间用 `Decoration.replace({})` 隐藏,并纳入 `EditorView.atomicRanges`:方向键整段跳过、退格一次删净; +- **仅对「纯标记区间」原子化,绝不对含可编辑内容的区间原子化** —— 例如链接只把 `[` 与 `](url)` 两个子区间原子化,中间文本仍可正常进入编辑; +- 破坏行高/换行的块级装饰(围栏折叠、水平线、图片)**必须用 `StateField` 直接 `provide: EditorView.decorations`**;行内隐藏用 `ViewPlugin` + `view.visibleRanges`(CM6 硬性约束:间接装饰在视口计算后取,块级 widget 会错位)。 + +### 4.3 行内渲染 + +定界符隐藏后,内容区间用 `Decoration.mark({ class })` 上样式,类名对齐阅读态:`.cm-md-strong`(font-weight 600)、`.cm-md-em`(italic)、`.cm-md-del`(删除线 + `--muted-foreground`)、`.cm-md-code`(等宽 + `--muted` 底 + 圆角,对齐 `index.css:852-857`)、`.cm-md-link`(下划线 + `--border-strong`,对齐 `index.css:919-927`)。 + +### 4.4 块级渲染 + +- **标题**:隐藏 `#` 后该行加 `.cm-md-h1..h6`,字号/字体族/间距对齐 `index.css:817-838`; +- **引用**:整行加 `.cm-md-quote`(左内边距 + 左边框,对齐 `index.css:840-847`),`>` 由竖线 widget 承接(§5.3); +- **代码块**:首行围栏替换为语言标签 widget,块内各行加 `.cm-md-fenced`(`--muted` 底 + 圆角),代码文本保持 `--font-mono`; +- **表格**:整表各行加 `.cm-md-table`(等宽 + 13.5px,对齐 `index.css:903-917`),`|` 与分隔行 `---` 用 `.cm-md-table-mark` 淡化(**不隐藏** —— 隐藏竖线会破坏列对齐的可读性)。 + +### 4.5 光标处展开(reveal) + +Typora 的核心手感,实现为**装饰集是 `state.selection` 的函数**: + +``` +activeLine = state.doc.lineAt(state.selection.main.head) +对每个候选隐藏区间: + 若与 activeLine 相交 → 撤销该处 replace(显示源码),保留样式 mark + 否则 → 隐藏 +``` + +- **v1 用行粒度**(光标所在行的语法标记整体展开),实现简单、行为可预期、对 IME 最安全; +- **未聚焦时整篇按渲染态呈现**(决策 E11):编辑器失焦 = 只读观感,避免「首行因光标停在行首而永远显示 `#`」; +- 代码围栏处于活动状态时同样展开围栏,便于改语言标识; +- 细化到 Typora 的 span 级展开列入 §10.2,不在 v1 承诺。 + +### 4.6 排版一致性(共享 typography 层) + +现有正文排版硬编码在 `.pn-note-body`(`index.css:805-933`),装饰态用的是 `.cm-line` / span,无法直接复用选择器。做法: + +1. 把排版参数抽为 CSS 变量(`--md-body-size: 15px`、`--md-body-lh: 1.8`、`--md-h1-size: 26px` …); +2. `.pn-note-body` 的规则与装饰态类(`.cm-md-*`)**共同引用同一组变量**,杜绝两份样式漂移; +3. 编辑区正文族与字号对齐阅读态(`--font-sans` / 15px / 1.8),**仅 `.cm-md-code` 与 `.cm-md-fenced` 用 `--font-mono`**;当前 CM 全局等宽字号 14.5px(`index.css:1944-1948`)需调整; +4. 即时渲染模式下**关闭行号与活动行高亮**(Typora 无行号),`Editor.tsx:109` 的 `basicSetup` 相应调整。 + +### 4.7 源码模式(逃生舱) + +- 用 `Compartment`(`@codemirror/state`)包裹 `livePreview` 扩展集,开关即整体挂载/卸载,O(1) 切换、无残留装饰; +- `AdminEdit.tsx` 现有「预览」按钮(`AdminEdit.tsx:204-209`)语义改为**「源码模式」开关**,`showPreview` 分屏块(`AdminEdit.tsx:282-286`)删除; +- 进入源码模式时恢复行号与等宽,与现状一致。 + +### 4.8 与既有功能的衔接 + +| 功能 | 变化 | +| --- | --- | +| 图片粘贴/拖拽上传 | **不变**。仍插入 `![alt](/api/images/{id})` 文本,装饰器随即把它渲染为图片 | +| 自动保存(防抖 2s) | **不变**。`onChange` 语义与频率不变 | +| 工具栏插入 | **不变**。插入的仍是 Markdown 片段,插入后立即渲染 | +| 主题(明/暗/跟随) | **不变**。装饰类全部走 CSS 变量,自动随主题 | +| 拖拽遮罩 | **不变**(`Editor.tsx:124-128`) | + +--- + +## 5. Widget 规格 + +### 5.1 无序列表圆点(决策 E2) + +| 项 | 规格 | +| --- | --- | +| 触发 | `ListMark` ∈ `BulletList`(源文本 `-` / `*` / `+`) | +| 装饰 | `Decoration.replace({ widget: BulletWidget })`,**原子** | +| 呈现 | `