Compare commits
11
Commits
92d89717c5
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cdb56bdd | ||
|
|
f80cb74d11 | ||
|
|
9fcd8cd7cd | ||
|
|
5c5ab3dfc3 | ||
|
|
9b0aff6f9f | ||
|
|
b1ea89ba3d | ||
|
|
9b0f2785cf | ||
|
|
ccc858bfdd | ||
|
|
2be5d4d25b | ||
|
|
95cac4688d | ||
|
|
ad4a87fc9f |
@@ -0,0 +1,109 @@
|
||||
# 发版工作流:推送 v* 标签 → 构建前端并嵌入 → 交叉编译多平台二进制
|
||||
# → 生成发布说明 → 通过 Gitea API 创建 Release 并上传产物。
|
||||
#
|
||||
# 发版操作:
|
||||
# git tag -a v0.0.1 -m "v0.0.1"
|
||||
# git push origin v0.0.1
|
||||
#
|
||||
# 实现说明:刻意不使用任何 JS 类 action(checkout/setup-*/release-*)——
|
||||
# 自托管 runner 常把实例地址注册成 localhost,job 容器内不可达,
|
||||
# 且默认容器镜像可能没有 node。此处全程纯 run 步骤 + 公网域名访问实例,
|
||||
# 容器固定 node:24-bookworm(自带 node 与 git,npm 构建前端所需)。
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
SITE: https://git.airnan.cn
|
||||
REPO: wangairnan/pure-note
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm
|
||||
env:
|
||||
# 这台 runner 只保证 GITHUB_* 兼容层(GITEA_REF_NAME 等别名不可靠),
|
||||
# 一律经表达式显式注入,不直接读 GITEA_* 环境变量
|
||||
TAG: ${{ github.ref_name }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN || secrets.FORGEJO_TOKEN }}
|
||||
steps:
|
||||
# 完整历史:供 git log 生成上一个 tag 以来的变更日志
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git clone "$SITE/$REPO.git" .
|
||||
git checkout --detach "$TAG"
|
||||
git log --oneline -1
|
||||
|
||||
- name: 安装 Go(版本取自 go.mod)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GOVER=$(awk '$1 == "go" {print $2}' go.mod)
|
||||
echo "==> 安装 Go $GOVER"
|
||||
# go.dev 在部分网络不可达,回退官方中国镜像
|
||||
curl -fsSL "https://go.dev/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz \
|
||||
|| curl -fsSL "https://golang.google.cn/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz
|
||||
tar -C /usr/local -xzf /tmp/go.tgz
|
||||
/usr/local/go/bin/go version
|
||||
|
||||
- name: 构建前端并嵌入(web/dist → internal/webui/dist)
|
||||
run: make web sync-assets || (npm config set registry https://registry.npmmirror.com && make web sync-assets)
|
||||
|
||||
- name: 交叉编译发布产物
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH=/usr/local/go/bin:$PATH
|
||||
# 模块代理双层回退:官方代理不可达时走 goproxy.cn
|
||||
export GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct
|
||||
scripts/build-release.sh "$TAG"
|
||||
|
||||
# 首个 tag 无上一版本,变更日志段自动省略
|
||||
- name: 生成发布说明
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev=$(git describe --tags --abbrev=0 "HEAD^" 2>/dev/null || true)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
{
|
||||
echo "## pn ${TAG}"
|
||||
echo
|
||||
echo "- commit: \`$short\`"
|
||||
echo "- 构建时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
if [ -n "$prev" ]; then
|
||||
echo
|
||||
echo "### 自 ${prev} 以来的变更"
|
||||
echo
|
||||
git log --oneline --no-decorate "${prev}..HEAD" | sed 's/^/- /'
|
||||
fi
|
||||
} > release-notes.md
|
||||
cat release-notes.md
|
||||
|
||||
# 重跑幂等:release 已存在时更新说明、清旧附件后重传
|
||||
- name: 创建 Release 并上传产物
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="$SITE/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
REQ=$(node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"pn "+process.env.TAG,body:fs.readFileSync("release-notes.md","utf8")}))')
|
||||
# 同 tag release 已存在(如失败重跑)时回退为查询
|
||||
REL=$(curl -fsS -X POST "$API/releases" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ") \
|
||||
|| REL=$(curl -fsS "$API/releases/tags/${TAG}" -H "$AUTH")
|
||||
RID=$(REL="$REL" node -e 'process.stdout.write(String(JSON.parse(process.env.REL).id))')
|
||||
echo "==> release id=$RID"
|
||||
# 发布说明以本次构建为准(回退路径下 POST 已冲突,须补写)
|
||||
curl -fsS -X PATCH "$API/releases/$RID" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ" >/dev/null
|
||||
# 清除旧附件,避免重跑产生同名副本
|
||||
ASSETS=$(curl -fsS "$API/releases/$RID/assets" -H "$AUTH")
|
||||
ASSETS="$ASSETS" node -e 'JSON.parse(process.env.ASSETS).forEach(a=>console.log(a.id))' | while read -r aid; do
|
||||
curl -fsS -X DELETE "$API/releases/$RID/assets/$aid" -H "$AUTH" >/dev/null
|
||||
done
|
||||
for f in dist/*; do
|
||||
echo "==> 上传 $(basename "$f")"
|
||||
curl -fsS -X POST "$API/releases/$RID/assets?name=$(basename "$f")" -H "$AUTH" -F "attachment=@$f" >/dev/null
|
||||
done
|
||||
echo "==> 完成:$SITE/$REPO/releases/tag/${TAG}"
|
||||
@@ -13,5 +13,8 @@ internal/webui/dist/*
|
||||
/pn
|
||||
/pn-linux-*
|
||||
|
||||
# 发布产物(make dist / CI 发版构建输出)
|
||||
/dist/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
@@ -5,17 +5,21 @@
|
||||
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
|
||||
# make build 全量构建(前端 + 单二进制 pn)
|
||||
# make linux 交叉编译 linux/amd64
|
||||
# make dist 全平台发布产物 → dist/(VERSION ?= git describe,与 CI 发版共用脚本)
|
||||
# make test Go 测试 + 前端测试
|
||||
# make smoke 真实构建冒烟测试(§8.3-6)
|
||||
# make dev 双进程热重载(air + vite dev)
|
||||
# make vulncheck govulncheck
|
||||
#
|
||||
# 发版:git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1
|
||||
# (推送 v* 标签触发 .gitea/workflows/release.yml 自动构建并创建 Release)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
||||
|
||||
.PHONY: web sync-assets build linux test smoke dev vulncheck clean
|
||||
.PHONY: web sync-assets build linux dist test smoke dev vulncheck clean
|
||||
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
@@ -33,6 +37,10 @@ linux: web sync-assets
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
|
||||
-o pn-linux-amd64 ./cmd/pn
|
||||
|
||||
# 全平台发布产物(版本注入逻辑与 CI 发版共用 scripts/build-release.sh)
|
||||
dist: web sync-assets
|
||||
scripts/build-release.sh "$(VERSION)"
|
||||
|
||||
test:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
@@ -51,3 +59,4 @@ vulncheck:
|
||||
|
||||
clean:
|
||||
rm -f pn pn-linux-amd64
|
||||
rm -rf dist
|
||||
|
||||
@@ -78,6 +78,22 @@ sudo systemctl enable --now pn pn-maint.timer
|
||||
|
||||
**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。
|
||||
|
||||
## 版本与发版
|
||||
|
||||
版本号以 git tag 为唯一来源(语义化版本,从 `v0.0.1` 开始),构建时经 ldflags 注入,
|
||||
`./pn version` 可查看:
|
||||
|
||||
```bash
|
||||
git tag -a v0.0.1 -m "v0.0.1"
|
||||
git push origin v0.0.1 # 推送 v* 标签即触发自动发版
|
||||
```
|
||||
|
||||
推送 `v*` 标签后,Gitea Actions([.gitea/workflows/release.yml](.gitea/workflows/release.yml))
|
||||
自动构建前端并嵌入,交叉编译 linux/darwin(amd64 + arm64)共 4 个产物,
|
||||
连同 SHA256SUMS 与上一版本以来的变更日志一起发布到仓库的 Release 页面。
|
||||
|
||||
本地验证发布产物:`make dist`(版本缺省 `git describe --tags --always`,输出到 `dist/`)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
|
||||
+33
-3
@@ -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 入库。
|
||||
|
||||
## 七、遗留事项(不阻塞验收)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+9
-3
@@ -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` → 光标处插入 ``;
|
||||
- **slug 策略**:首次保存时由标题自动生成——中文标题退化为 `post-<YYYYMMDD>`,与现有记录(含回收站)冲突时追加 `-2`/`-3` 直至唯一;**首次保存即定稿,此后除非用户手动修改否则永不变化**;用户手改与他人冲突 → 服务端 409 + 字段级错误提示,前端就地高亮;
|
||||
- 自动保存:防抖 2s;大改动前本地兜底(失败时保留编辑态不丢内容)。
|
||||
|
||||
@@ -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` | 整段替换为 `<img>` widget(§5.4) |
|
||||
| 引用 | `Blockquote` + `QuoteMark` | `>` → 竖线 widget(§5.3) |
|
||||
| 列表 | `BulletList` / `OrderedList` + `ListMark` | 圆点 widget / 数字保留(§5.1、§5.2) |
|
||||
| 代码围栏 | `FencedCode` + `CodeMark` / `CodeInfo` | 起围栏 → 语言标签 widget;闭围栏隐藏;块加 `.cm-md-fenced` |
|
||||
| 水平线 | `HorizontalRule` | 整行替换为 `<hr>` 块 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 与既有功能的衔接
|
||||
|
||||
| 功能 | 变化 |
|
||||
| --- | --- |
|
||||
| 图片粘贴/拖拽上传 | **不变**。仍插入 `` 文本,装饰器随即把它渲染为图片 |
|
||||
| 自动保存(防抖 2s) | **不变**。`onChange` 语义与频率不变 |
|
||||
| 工具栏插入 | **不变**。插入的仍是 Markdown 片段,插入后立即渲染 |
|
||||
| 主题(明/暗/跟随) | **不变**。装饰类全部走 CSS 变量,自动随主题 |
|
||||
| 拖拽遮罩 | **不变**(`Editor.tsx:124-128`) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Widget 规格
|
||||
|
||||
### 5.1 无序列表圆点(决策 E2)
|
||||
|
||||
| 项 | 规格 |
|
||||
| --- | --- |
|
||||
| 触发 | `ListMark` ∈ `BulletList`(源文本 `-` / `*` / `+`) |
|
||||
| 装饰 | `Decoration.replace({ widget: BulletWidget })`,**原子** |
|
||||
| 呈现 | `<span class="cm-md-bullet" aria-hidden="true">`,字符按嵌套深度取 `•`(深 1)/ `◦`(深 2)/ `▪`(深 3+) |
|
||||
| 深度判定 | 遍历语法树祖先中 `BulletList` / `OrderedList` 的个数(可靠,不依赖缩进列数) |
|
||||
| 对齐 | widget 宽度 = 原标记宽度,保证列表内容左边界不跳动 |
|
||||
| 交互 | v1 不可点击(改标记类型请切源码模式) |
|
||||
|
||||
### 5.2 有序列表(决策 E3)
|
||||
|
||||
| 项 | 规格 |
|
||||
| --- | --- |
|
||||
| 触发 | `ListMark` ∈ `OrderedList`(源文本 `1.` / `2)` …) |
|
||||
| 装饰 | **仅** `Decoration.mark({ class: 'cm-md-ol-mark' })`,**不替换、不原子** |
|
||||
| 理由 | 数字携带序号语义(起始序号、`3.` 这类非常规起点),替换为 widget 会让用户无法编辑;CM 亦不自动重排列表号 |
|
||||
| 呈现 | 数字加粗 + `--muted-foreground`,与圆点视觉重量接近 |
|
||||
|
||||
### 5.3 引用竖线(决策 E4)
|
||||
|
||||
| 项 | 规格 |
|
||||
| --- | --- |
|
||||
| 触发 | `QuoteMark`(源文本 `>`,嵌套时每层各一个) |
|
||||
| 装饰 | `Decoration.replace({ widget: QuoteBarWidget })`,**原子** |
|
||||
| 呈现 | `<span class="cm-md-quote-bar" aria-hidden="true">`,CSS 画 2px `var(--border-strong)` 竖线,**宽度 = 原标记宽度**,保证后续内容对齐 |
|
||||
| 嵌套 | 每层 `QuoteMark` 各出一个 widget,自然叠加为多道竖线(接近 Typora) |
|
||||
| 行样式 | 同行加 `Decoration.line({ class: 'cm-md-quote' })` 提供左内边距,视觉对齐 `index.css:840-847` |
|
||||
|
||||
### 5.4 其余 widget
|
||||
|
||||
| widget | 触发 | 呈现与行为 |
|
||||
| --- | --- | --- |
|
||||
| 图片 | `Image` 全节点 | `Decoration.replace` 成 `<img>`(`max-width:100%`、圆角,对齐 `index.css:893-897`);`src` 白名单见 §6;原子 |
|
||||
| 水平线 | `HorizontalRule` 整行 | `<hr>` 块 widget(**StateField 直接 provide**) |
|
||||
| 任务框 | `TaskMarker` | `<input type="checkbox">`;点击 dispatch 改写源文本 `[ ]` ↔ `[x]`(真相源仍是文本);原子 |
|
||||
| 围栏语言标签 | `CodeInfo` + 起围栏 | 语言名小标签;点击可切源码模式改语言 |
|
||||
| 链接 URL | `LinkMark` 的 `](url)` 段 | 隐藏;v1 不做悬浮预览,光标进入该行即展开可见 |
|
||||
|
||||
### 5.5 Widget 通用约束
|
||||
|
||||
1. **构造 DOM 一律用 `document.createElement` / `textContent` / `setAttribute`,禁止 `innerHTML`**;
|
||||
2. 所有 widget 视为装饰层私有产物,不接受用户 HTML;
|
||||
3. `toDOM` 返回值带 `aria-hidden="true"`(视觉替代物,不参与无障碍朗读;复选框除外,需带 `aria-label`);
|
||||
4. 块级 widget 必须走 `StateField` 直接 provide(§4.2);
|
||||
5. widget 不得改变文档内容与选区语义。
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全设计
|
||||
|
||||
装饰层不引入新的 XSS 面,但必须显式加固以下两条:
|
||||
|
||||
| 面 | 风险 | 措施 |
|
||||
| --- | --- | --- |
|
||||
| 图片 `src` | 用户可写 `` / 外链 | 仅允许 `^/api/images/` 与 `^data:image/`;其余不发 `<img>`,降级为源码文本 |
|
||||
| 链接 URL | 用户可写 `[x](javascript:…)` | v1 链接**只做文本样式,不渲染 `<a>`**,无点击行为 → 无协议执行面;日后若加「点击打开」,必须复用 `sanitize.ts` 的 http/https/mailto 白名单 |
|
||||
| widget DOM | 注入执行 | 全程 `createElement`/`textContent`,零 `innerHTML`(§5.5) |
|
||||
| CSP | 现有策略 | **不变**。`style-src 'unsafe-inline'` 的既有豁免仍由 CodeMirror `style-mod` 需要(见 `review-round1.md` 第 18 项);装饰类走静态 CSS,不增加 CSP 面 |
|
||||
|
||||
公开页渲染管线(`MarkdownViewer` + `rehype-sanitize`)与 `tests/markdown.test.tsx` 的断言**全部保持有效**,本次不改动该链路。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
### 7.1 中文输入法(IME)—— 最高风险
|
||||
|
||||
替换装饰会在组合输入过程中改动 DOM,CodeMirror 官方 issue 中此类缺陷集中:
|
||||
|
||||
| issue | 现象 |
|
||||
| --- | --- |
|
||||
| [dev#1654](https://github.com/codemirror/dev/issues/1654) | 装饰与 IME 组合输入冲突 |
|
||||
| [dev#1650](https://github.com/codemirror/dev/issues/1650) | 语法高亮边界处组字乱码 |
|
||||
| [dev#1684](https://github.com/codemirror/dev/issues/1684) | Chrome 上中文 IME 删掉前文 |
|
||||
| [dev#1688](https://github.com/codemirror/dev/issues/1688) | 括号内组字后文本视觉消失 |
|
||||
|
||||
**缓解**:
|
||||
|
||||
1. `view.composing === true` 期间**冻结装饰更新**(`view.composing` 为 CM6 公开 API,已核实),绝不替换组合范围内的 DOM;
|
||||
—— 已实现(`livePreview.ts` 中组合期间只做 `decorations.map(changes)`,不重建);
|
||||
2. reveal 用行粒度 —— 光标所在行本就是展开态,组字中途不会被替换;
|
||||
3. 原子区间只作用于已隐藏的纯标记范围(不变量有单测守护,§12.5.3);
|
||||
4. **上线前必须用拼音 / 搜狗 / 系统输入法真机回归,这是自动化测不出来的硬门槛** ——
|
||||
已形式化为 §12.6 的**人工验收门槛(阻塞发布)**,含 8 项用例与签字要求;当前状态:未执行。
|
||||
|
||||
### 7.2 选区与光标
|
||||
|
||||
已知边角:多行 replace 破坏光标定位([#1658](https://github.com/codemirror/dev/issues/1658))、块 widget 致选区渲染异常([#1406](https://github.com/codemirror/dev/issues/1406))、相邻 widget 间移动异常([#979](https://github.com/codemirror/dev/issues/979))。缓解:避免多行 replace(逐行处理);对替换区间原子化。
|
||||
|
||||
**实测结论(P3,2026-09-11)**:读 CM6 源码可知 `EditorView.atomicRanges` 的消费点有两处 ——
|
||||
`view` 包内 `skipAtomsForSelection`(鼠标指针选区,`view/dist:4317`)与 `@codemirror/commands` 的
|
||||
`skipAtomic`(键盘移动/删除)。**因为 reveal 是行粒度,光标所在行永远是展开态**,所以原子区间在
|
||||
键盘输入路径上几乎不起作用(退格只会命中「当前行之前」的换行符,而非本行标记);它的真实作用面是
|
||||
**鼠标点击/拖拽**:锚点落在已隐藏的标记内部时被吸附到标记边界。这不是缺陷(行粒度下「本行永远可编辑」
|
||||
本就是设计目标,决策 E6),而是需要如实记录的作用边界;若日后细化到 span 级 reveal(§10.2),
|
||||
原子区间才会在键盘路径上变成关键机制。
|
||||
|
||||
边界回归见 §12.5:文首/文末构造、全选删除、跨标记选区替换、原子区间四项不变量(不跨行 / 有序不重叠 /
|
||||
不含可编辑内容 / 隐藏标记必被原子化)。
|
||||
|
||||
### 7.3 块 widget 与布局
|
||||
|
||||
"[#575](https://github.com/codemirror/dev/issues/575) 视口外行不渲染"、"[#625](https://github.com/codemirror/dev/issues/625) 文末块 widget 消失":本实现不使用 block widget(决策 E10),水平线以整行
|
||||
inline-block 承接、围栏闭标记只隐藏内容,因此这两类缺陷的结构性成因不存在。仍单独回归了「文首水平线/列表/围栏/任务」
|
||||
「文末水平线/图片/围栏/任务」「整篇只有一条水平线」「文末追加内容」等边界(§12.5),全部通过。
|
||||
|
||||
### 7.4 性能
|
||||
|
||||
装饰计算只遍历 `view.visibleRanges`(外加选区端点行),不整篇扫描;语法树由 lezer 增量维护。
|
||||
目标:3000 行文档单帧 < 16ms。**P3 已实测,结论见 §12.5**,摘要:
|
||||
|
||||
- 视口窗口(约 100 行)装饰采集中位数 **0.26ms**;全篇作为单一区间的极端情况中位数 **5.0ms**(< 16ms);
|
||||
- 真实编辑器输入(3000 行文档、文末处连打 40 字)同步耗时中位数 **0.1ms**、最大 0.7ms;
|
||||
- 冷启动从文档开头直跳文末:帧间隔最大 **7.1ms**、**零 long task**(无卡顿);
|
||||
- 整篇 3000 行(15.6 万字符)全量解析 **15.4ms**,因此 §12.5 的「补解析预算」常态下不会真正被耗尽。
|
||||
|
||||
长文档的真实风险不是构建成本,而是 **lezer 的惰性解析**:`syntaxTree(state)` 首次只覆盖前约 3000 字符,
|
||||
若不做处理,未解析区间会显示裸 Markdown(P3 修复,见 §12.5 与决策 E13)。
|
||||
|
||||
### 7.5 兼容与降级
|
||||
|
||||
风险兜底为源码模式(§4.7)。若某类文档装饰层出现无法及时修复的问题,可只对受影响语法节点关闭隐藏(配置项 + 单测),用户无感降级。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试方案
|
||||
|
||||
| 层 | 用例 | 落地 |
|
||||
| --- | --- | --- |
|
||||
| 纯函数单测(vitest,无 DOM) | `syntax.ts` 区间识别(嵌套强调、代码块内转义、中文标点、表格分隔行);`reveal.ts` 行粒度展开与二分/线性等价 | `tests/editor-live-preview.test.ts` |
|
||||
| 安全单测 | widget `toDOM` 输出无 `<script>`、无 `innerHTML`;图片 `src` 非白名单时降级为文本;链接不产出 `<a>` | 同上 |
|
||||
| 组件冒烟(happy-dom 真实 EditorView) | 非活动行标记已隐藏、活动行标记可见、原子区间生效;文首/文末构造;全选删除;不变量 | `tests/editor-boundaries.test.ts` |
|
||||
| 性能基准 | 3000 行视口/全篇装饰计算中位数、全量解析成本、最坏选区(Cmd+A)构建成本 | 同上「性能基准」组 |
|
||||
| 真机回归(**人工门槛,阻塞发布**) | 拼音/搜狗/系统输入法连续输入中文 + 标点;复制粘贴;撤销重做;文首/文末 | §12.6 清单,未通过不得发布 |
|
||||
| 既有回归 | `tests/markdown.test.tsx` 全绿(公开页管线不受影响);`make smoke` 全绿 | 已通过 |
|
||||
|
||||
实测中发现 happy-dom 能真实创建 `EditorView`(含 `view.focus()`、装饰 DOM、`atomicRanges` facet),
|
||||
因此边界与原子化行为用真实 view 断言,不再只测纯函数。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施拆解
|
||||
|
||||
| 阶段 | 内容 | 风险 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| **P0 骨架** | `syntax.ts` + `decorations.ts` + `reveal.ts` + `livePreview.ts`;覆盖标题/粗体/斜体/行内代码/链接;行粒度 reveal;关闭行号;接入 `Compartment` 源码开关 | 低,手感提升最明显 | ✅ 已完成 |
|
||||
| **P1 块级 widget** | 图片、水平线、任务列表(可点)、围栏语言标签 | 中(块 widget 布局) | ✅ 已完成 |
|
||||
| **P2 列表与引用 + 排版一致性** | 圆点(§5.1)、引用竖线(§5.3)、代码块底色;抽出共享 typography 变量层,编辑态对齐 `.pn-note-body` | 中 | ✅ 已完成 |
|
||||
| **P3 加固** | IME 冻结、选区边界、性能基准、文首/文末边界;删除分屏预览 | 中高(IME 必须真机) | ✅ 代码与自动化部分完成(IME 冻结、分屏删除、选区/文首文末边界回归、3000 行基准实测);**真机 IME 回归是人工门槛,见 §12.6,未通过不得发布** |
|
||||
|
||||
每阶段交付:实现 + 对应单测 + 该阶段回归清单。P0 完成后即可替换现有分屏默认路径。
|
||||
实际交付与验证证据见 §12。
|
||||
|
||||
---
|
||||
|
||||
## 10. 后续方向(明确记录,不在 v1 范围)
|
||||
|
||||
1. **表格(v1 只做源码对齐)**:后续可做 HTML 表格渲染 + 行列增删的可视化编辑。技术要点:块级 `Decoration.replace` 为 `<table>` widget(必须 StateField 直接 provide)+ 单元格编辑回写 Markdown 文本 + GFM 对齐语法(`:---:`)保真;这是复杂度黑洞,需单独立项与完整回归。
|
||||
2. **span 级 reveal**:把 §4.5 从行粒度细化到 Typora 的「光标所在行内节点」粒度,需先解决 7.1/7.2 的 IME 与光标问题。
|
||||
3. **代码块增强**:围栏内复用 `highlight.js`(公开页已用 `rehype-highlight`)做与阅读态一致的高亮;语言选择器。
|
||||
4. **数学与图表**:KaTeX / Mermaid 需**编辑态与公开页同步引入**,否则「所见即所得」不成立;且要评估 bundle 与 CSP(KaTeX 需字体、Mermaid 需 `unsafe-eval` 类风险),单独评审。
|
||||
5. **脚注 / 定义列表 / 目录**:lezer 支持后按 §4.1 表扩展。
|
||||
6. **无障碍**:widget 的 `aria-label` 与键盘可操作性补全(任务框、语言标签)。
|
||||
7. **移动端/触屏**:选中与原子区间的触屏行为回归。
|
||||
8. **大文档**:3000 行以上基准与必要时的装饰节流。→ P3 已完成 3000 行基准(§12.5);更大规模(1 万行+)与装饰节流仍未做。
|
||||
|
||||
---
|
||||
|
||||
## 11. 本文档决策记录
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| E1 | 采用 CM6 装饰器路线(路线 A),不引入富文本模型 | §2.3;零新增依赖、真相源不变、回归面最小;符合 `design.md:118/684` |
|
||||
| E2 | 无序列表标记替换为**圆点 widget**(原子,按嵌套深度 `•`/`◦`/`▪`) | 用户决策;更接近 Typora;宽度对齐避免内容跳动 |
|
||||
| E3 | 有序列表标记**保留数字**,仅上样式,不替换不原子 | 数字携带序号语义且 CM 不自动重排,替换会导致无法编辑起始序号 |
|
||||
| E4 | 引用 `>` 替换为**竖线 widget**(宽度=原标记宽度,原子),嵌套自然叠加多道竖线 | 用户决策;视觉对齐 `index.css:840` 的 `blockquote` |
|
||||
| E5 | 表格 v1 只做源码等宽对齐 + 定界符淡化,不渲染 HTML | 按用户确认;表格可视化编辑复杂度高,列 §10.1 后续方向 |
|
||||
| E6 | reveal 粒度 v1 取**行级**,span 级列 §10.2 | 行级对 IME 最安全、行为可预期;先保正确性再求细腻 |
|
||||
| E7 | 保留源码模式开关(`Compartment` 整体挂载/卸载)作为逃生舱 | 装饰层出 bug 时用户可退回;对应 Typora Cmd/Ctrl+/ |
|
||||
| E8 | 不修改公开页渲染管线与 sanitize 管线 | 安全防线不动;`tests/markdown.test.tsx` 持续守护 |
|
||||
| E9 | 编辑区正文改用 `--font-sans` / 15px / 1.8,仅代码用等宽 | 与 `.pn-note-body` 视觉一致,否则「所见即所得」不成立 |
|
||||
| E10 | **全部装饰由单一 ViewPlugin 产出,不引入 StateField** | 实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),因此无需 StateField 的全文档遍历,也规避块 widget 的布局/选区风险;代价见 §12.2 D1 |
|
||||
| E11 | **reveal 以「编辑器聚焦」为前提**:未聚焦时整篇按渲染态呈现 | 否则载入即因光标在行首而显示 `#`,WYSIWYG 首屏观感受损;失焦=只读观感,与 Typora 一致 |
|
||||
| E12 | **图片与任务复选框不参与 reveal**,恒为渲染/可点状态 | 图片:源码模式作为改 URL 的出口,避免段落内行内图片在编辑该行时永远显示源码;复选框:参与 reveal 会导致光标落到任务行后无法点击 |
|
||||
| E13 | 装饰前用 `ensureSyntaxTree` 在 **20ms 预算**内补齐可见区间,并以「语法树对象换代」触发重建 | P3 实测 `syntaxTree(state)` 对 3000 行文档首次只覆盖 3061 字符(惰性解析),不补解析则未解析区间显示裸 Markdown;预算有界避免卡顿,补不动时自动等后台解析推进,见 §12.5 |
|
||||
| E14 | 装饰采集范围 = 可见区间 + 选区**端点**行(**不是**整个选区跨越的行) | CM 只把选区端点块渲染到视口之外;全量取会让 Cmd+A 在长文档上退化为全文档装饰构建,见 §12.5 |
|
||||
| E15 | reveal 判定改为「区间合并 + 二分」 | 整篇选区会产生数千个展开区间,原线性扫描 O(装饰数×区间数) 在长文档下失控;二分后最坏选区构建中位数 0.98ms,见 §12.5 |
|
||||
| E16 | **编辑区默认吃满视口剩余高度**,长文在编辑器内部滚动(页面不再随内容增长) | 用户要求(2026-09-11)。此前 `.pn-ae-body-wrap` 只有 `min-height: 420px` 且高度随内容增长:短文编辑区偏小,3000 行长文把页面撑到 8.4 万像素、只能靠页面滚动。落地与实测见 §12.7 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 实施记录(2026-09-11)
|
||||
|
||||
### 12.1 交付物
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `web/src/editor/syntax.ts` | lezer 语法树 → 装饰描述(Spec)纯函数;覆盖标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 / 任务 / 围栏 / 水平线 / 表格 |
|
||||
| `web/src/editor/reveal.ts` | 行粒度展开:`activeLineRanges` / `selectionEndpointLines` / `applyReveal`(合并区间 + 二分) |
|
||||
| `web/src/editor/widgets.ts` | `BulletWidget` / `QuoteBarWidget` / `HrWidget` / `ImageWidget` / `FenceLangWidget` / `TaskWidget` |
|
||||
| `web/src/editor/decorations.ts` | Spec → DecorationSet + 原子区间;图片 src 白名单;可见区间去重;`visibleSyntaxTree`(补解析) |
|
||||
| `web/src/editor/livePreview.ts` | ViewPlugin(可见区间 + doc/selection/viewport/focus/语法树换代 驱动)、`EditorView.atomicRanges`、`livePreviewCompartment`、组合输入冻结 |
|
||||
| `web/src/components/Editor.tsx` | 新增 `sourceMode` prop;`.pn-cm-live` 包裹类;compartment 热切换 |
|
||||
| `web/src/pages/AdminEdit.tsx` | 删除分屏预览块与 `MarkdownViewer` import;「预览」按钮改为「源码模式 / 退出源码」 |
|
||||
| `web/src/index.css` | `--md-*` 排版契约变量(阅读态与编辑态共用)+ `.cm-md-*` 装饰样式;移除 `.pn-ae-preview` |
|
||||
| `web/tests/editor-live-preview.test.ts` | 29 条单测:区间识别 / reveal 策略(含二分等价) / 构建与安全 |
|
||||
| `web/tests/editor-boundaries.test.ts` | 32 条回归:文首文末边界 / 跨原子区间选区 / 装饰层不变量 / 长文档惰性解析 / 性能基准 |
|
||||
|
||||
验证结果:前端 **71 条测试全绿**(新增 29 + 32,既有 10),`tsc --noEmit` 与 `npm run build` 通过,
|
||||
`make test`(go vet + go test + vitest)与 `make smoke`(构建→起服务→SPA/meta/安全头/可见性抽查)全绿。
|
||||
**运行时零新增 npm 依赖**;仅新增 `@codemirror/commands` 为 devDependency(它本就在依赖树中,用于边界回归里
|
||||
真实执行退格等编辑命令,不进入产物)。
|
||||
|
||||
### 12.2 与方案的偏差(均经实机验证后确定)
|
||||
|
||||
| # | 偏差 | 原因与代价 |
|
||||
| --- | --- | --- |
|
||||
| D1 | **不引入 StateField**,全部装饰由一个 ViewPlugin 产出 | 方案 §4.2/§5.4 要求块级装饰走 StateField;但实现里不存在真正的块级装饰:水平线用「整行内容的 inline-block(`width:100%`)」承接,围栏闭标记只隐藏内容并用行类延续底色。收益是免去 StateField 的全文档遍历与块 widget 的布局/选区风险(§7.3)。**代价**:闭围栏行留下一行空白,成为代码块底部内边距 |
|
||||
| D2 | 模块合并:`markers.ts` / `inline.ts` / `blocks.ts` → `syntax.ts`(识别)+ `decorations.ts`(构建) | 三个文件职责都是「节点 → 装饰」,拆分只增加跨文件跳转;单测边界改为「识别」与「构建/展开」两段,覆盖不减 |
|
||||
| D3 | 图片 widget **不参与 reveal**,恒渲染 | 与 Typora 一致(点图片不展开为源码);改 URL/alt 走源码模式(§4.7)。同时避免「段落中的行内图片在编辑该行时永远显示源码」 |
|
||||
| D4 | 任务复选框 **不参与 reveal**,恒可点 | 否则光标一落到任务行,复选框就变回 `[ ]` 文本,反而无法点击 |
|
||||
| D5 | 行号 / 活动行高亮用 CSS 隐藏(`.pn-cm-live .cm-gutters{display:none}`),不改 `basicSetup` | 避免依赖 `@uiw/react-codemirror` 对 `basicSetup` 的运行时重配置行为 |
|
||||
| D6 | 表格定界符产出单个 `tableMark` spec(带 `delimiter` 标记):普通竖线 55% 透明、分隔行 38% | 与 §4.4「淡化」一致,实现更简单;`TableDelimiter` 节点在 lezer 里对分隔行是整段而非单竖线 |
|
||||
| D7 | 列表圆点覆盖「标记 + 其后空格」,行内样式类名定为 `cm-md-strong/em/del/code/link` | 圆点宽度固定 `1.15em`,与「宽度=原标记宽度」的方案意图一致但更可控(原标记在比例字体下宽度不定) |
|
||||
|
||||
### 12.3 实机验证(内置浏览器,`pn start --dev` + `vite dev`)
|
||||
|
||||
- **渲染**:标题 / 粗体 / 斜体 / 删除线 / 行内代码 / 链接 / 引用(嵌套两道竖线)/ 无序列表(`•`/`◦`/`▪` 三级)/
|
||||
有序列表(保留 `1.` `2.` `3.`)/ 任务框 / 图片(480×220 原尺寸 + 12px 圆角 + 边框)/ 水平线 /
|
||||
围栏(`js` 标签 + 底色圆角)/ 表格(等宽 + 竖线淡化)全部按预期呈现;
|
||||
- **展开**:点击段落行 → 该行显示 `**粗体**` 等源码、其他行标记保持隐藏;聚焦标题行显示 `# `,失焦后恢复整篇渲染(E11);
|
||||
- **交互**:点击复选框 → 源文本 `- [ ]` 改写为 `- [x]`,2s 防抖自动保存后服务端确实收到改写(读回 API 确认);
|
||||
- **源码模式**:切换后行号恢复、装饰全部卸载(无 `cm-md-*` 残留),连续切换 4 次无异常、无 console 错误;
|
||||
- **中文输入**:`insertText` 通路追加中文,以及合成 `compositionstart → 输入 → compositionend` 组合输入,
|
||||
文本无重复/乱序、无 console 错误,其他行装饰保持;
|
||||
- **零回归**:公开阅读页 `.pn-note-body` 计算样式未变(正文 15px / 行高 27px、标题衬线、段落下边距 24px);
|
||||
`tests/markdown.test.tsx` 全绿。
|
||||
|
||||
### 12.4 未完成 / 待办
|
||||
|
||||
1. **真机输入法回归 —— 人工验收门槛,阻塞发布(§12.6)**:自动化只能合成组合事件;拼音 / 搜狗 /
|
||||
系统输入法的连续中文 + 标点输入必须人工真机走查。
|
||||
2. 表格 HTML 渲染与可视化编辑(§10.1)、span 级 reveal(§10.2)、代码块高亮(§10.3)仍按方案留作后续。
|
||||
3. 图片 widget 目前按原尺寸显示;超大图依赖 `max-width:100%` 收敛,未做高度上限与点击放大(§10.7 范畴)。
|
||||
4. 1 万行以上文档与装饰节流未做(§10.8);当前实测覆盖到 3000 行。
|
||||
|
||||
### 12.5 P3 加固记录(2026-09-11)
|
||||
|
||||
#### 12.5.1 修掉的两个真实缺陷
|
||||
|
||||
| # | 现象 | 根因 | 修复 |
|
||||
| --- | --- | --- | --- |
|
||||
| P3-1 | 长文档中**未解析区间显示裸 Markdown**(3000 行文档实测:文档开头以外全部是 `**粗体**`、`## 标题` 原文) | lezer 是惰性增量解析:`EditorState` 建好后 `syntaxTree(state)` 只覆盖前 3061 字符(15.5 万字符文档),越界区间采集不到任何 spec;且后台解析推进时事务没有 doc/selection 变化,ViewPlugin 不会重算 | ① 新增 `visibleSyntaxTree()`:用 `ensureSyntaxTree(state, upto, 20ms)` 在有限预算内补齐可见区间;② ViewPlugin 增加「语法树对象换代」触发条件(`syntaxTree(startState) !== syntaxTree(state)` → 重建),后台解析推进后装饰自动补齐 |
|
||||
| P3-2 | `Cmd+A` 全选在长文档下会构建**全文档**装饰;reveal 判定是 O(装饰数×区间数) 线性扫描 | ① 采集范围把「选区跨越的每一行」都算进去了;② `applyReveal` 对每个 spec 线性扫全部展开区间 | ① 采集范围改为「可见区间 + 选区**端点**行」(CM 也只渲染端点块);② `applyReveal` 先合并区间再二分(E14/E15) |
|
||||
|
||||
两项均补了回归:`visibleSyntaxTree` 补解析断言、越界不越界断言、光标行装饰断言、二分/线性等价断言、
|
||||
最坏选区(Cmd+A)构建耗时基准。
|
||||
|
||||
#### 12.5.2 性能实测结果
|
||||
|
||||
**环境**:本机 macOS arm64 / Chromium(内置浏览器,~144Hz 刷新);vitest 组运行于 happy-dom(Node 22)。
|
||||
**负载**:3000 行 / 3004 行、15.5–15.6 万字符的混合文档(每 7 行一个 h2、每 5 行一个含粗体/行内代码/链接的列表项、
|
||||
每 11 行一个引用,其余为含强调/斜体/行内代码/链接的正文)。
|
||||
|
||||
| 指标 | 方法 | 结果 | 判定 |
|
||||
| --- | --- | --- | --- |
|
||||
| 视口窗口(100 行)装饰采集 | `collectSpecs` ×40 取中位数(vitest) | 中位数 **0.26ms**,p95 0.83ms | ✅ ≪ 16ms(§7.4 目标) |
|
||||
| 全篇作为单一可见区间(最坏采集) | `collectSpecs` 全文档 ×10 取中位数 | 中位数 **5.0ms**,最大 12.1ms | ✅ < 16ms |
|
||||
| 最坏选区(Cmd+A 整篇选中 + 聚焦) | `buildDecorations` ×10 取中位数 | 中位数 **0.98ms**,最大 1.73ms | ✅ < 16ms |
|
||||
| 整篇全量解析(冷启动一次性) | `visibleSyntaxTree(tree=null → 全篇)` | **15.4ms**(覆盖 155327 字符) | 一次性成本,20ms 预算内 |
|
||||
| 真实编辑器输入(文末普通段落,连打 60 字) | `execCommand` 同步返回耗时 ×60 取中位数 | 中位数 **1.5ms**,p95 1.8ms,最大 2.0ms | ✅ |
|
||||
| 真实编辑器输入(中段列表项,装饰更密) | 同上 ×45 | 中位数 **2.2ms**,p95 2.5ms,最大 6.1ms | ✅ |
|
||||
| 输入到下一帧(含浏览器排版/绘制/帧调度) | 同上一循环内 measure 到 rAF | 中位数 **13.9–18.3ms**、p95 22.8–28.5ms(≈1–3 帧 @60Hz 口径) | ✅ 无可感卡顿;该指标受帧调度与绘制影响,装饰层自身成本即上面的同步耗时 |
|
||||
| 冷启动直跳文末(编辑器内部滚动,跳过未解析区) | rAF 帧间隔 + `longtask` 观察器 | 帧间隔最大 **8.6ms**(≈1 帧),**long task 数 0** | ✅ 无掉帧 |
|
||||
| 跳转后文末渲染完整性 | 断言可见行装饰数与末行文本 | 34 行渲染 / 32 行带装饰,末行 `文末正文 粗 结束。` 无标记泄漏 | ✅ |
|
||||
|
||||
结论:**§7.4 的「3000 行单帧 < 16ms」目标达成**(装饰采集 0.26–5.0ms,真实输入同步耗时中位数 1.5–2.2ms)。
|
||||
`ensureSyntaxTree` 的 20ms 预算在实测中甚至未被真正耗尽(整篇解析仅 15.4ms),因此不构成卡顿来源。
|
||||
|
||||
> 输入类指标为**编辑区吃满视口 + 内部滚动**落地后重测(见 §12.7);滚动类指标同样在内部滚动下重测。
|
||||
|
||||
#### 12.5.3 边界与选区回归(`tests/editor-boundaries.test.ts`,32 条)
|
||||
|
||||
| 组 | 覆盖 |
|
||||
| --- | --- |
|
||||
| 文首边界(5) | 标题 / 任务项 / 围栏 / 水平线开头的渲染与原子化;光标在文档起点退格为无操作且不损坏内容 |
|
||||
| 文末边界(7) | 水平线 / 图片 / 围栏 / 任务结尾的 widget 渲染;整篇只有一条水平线;文末追加内容后装饰正确重建;全选清空不抛错 |
|
||||
| 跨原子区间选区(5) | 全选替换、跨隐藏标记的选区替换、只选中可见内容时标记完整保留、连标记整体删除、跨行删除后装饰重建 |
|
||||
| 装饰层不变量(6) | 原子区间不跨行(CM 硬性约束)/ 有序不重叠 / 隐藏标记必在原子区间内 / 强调·行内代码·链接的内容区间绝不被原子化 / reveal 契约(进入行→原子消失,离开→恢复)/ 失焦整篇渲染 |
|
||||
| 长文档惰性解析(5) | `syntaxTree` 首覆盖仅 3061 字符的事实、`visibleSyntaxTree` 补齐、预算受限时越界保持源码态、装饰不越过已解析边界、光标行在视口外也被装饰 |
|
||||
| 性能基准(6) | 上表前五项 + 最坏选区(Cmd+A)构建成本 |
|
||||
|
||||
交互面(真实浏览器)另验证:文首连续退格不损坏内容;`Cmd+A` + 退格清空 3000 行文档后 `Cmd+Z` 完整恢复;
|
||||
远距离滚动后光标所在块仍以渲染态出现(P3-2 修复点)。
|
||||
|
||||
### 12.6 人工验收门槛:真机输入法回归(**未通过不得发布**)
|
||||
|
||||
自动化无法产生真实 IME 合成事件,冻结逻辑(`view.composing` 为真时仅映射、不重建装饰)虽已实现且
|
||||
通过了合成 `compositionstart/…/compositionend` 冒烟,但**未经过真实输入法验证**,因此列为
|
||||
**阻塞发布的人工验收项**,与 `docs/acceptance.md` 的 M5 门槛一致。
|
||||
|
||||
**执行要求**:在真实浏览器 + 真实输入法(至少覆盖「系统拼音」与「搜狗拼音」各一遍)中逐项走查:
|
||||
|
||||
| # | 用例 | 期望 |
|
||||
| --- | --- | --- |
|
||||
| I1 | 在空行用拼音连续输入中文(含候选词上下屏、翻页) | 文字正确上屏,无重复/丢字/乱序,装饰不闪断 |
|
||||
| I2 | 在已有 `**粗体**` 行内、光标位于标记中间开始组字 | 该行保持展开态,组字过程中 DOM 不被替换,无「视觉消失」 |
|
||||
| I3 | 在行首/行尾紧邻隐藏标记处组字 | 不吞前文、不把标记一起吃掉(对照 CM issue #1684/#1688) |
|
||||
| I4 | 中文标点(,。、;:「」()——)连续输入 | 标点正确、不触发误解析 |
|
||||
| I5 | 组字过程中移动光标 / 点击其他行 | 不出现错位、重复上屏或崩溃 |
|
||||
| I6 | 列表项、引用、标题行内组字 | 行级 reveal 生效,标记不被误删 |
|
||||
| I7 | 中文输入后撤销/重做(Cmd+Z / Shift+Cmd+Z) | 按「一次组字一次撤销」的直觉工作 |
|
||||
| I8 | 3000 行文档中组字 | 无卡顿(输入同步耗时已在 §12.5 实测为亚毫秒级) |
|
||||
|
||||
**签字**:执行人 / 日期 / 浏览器与输入法版本 / 结果(通过 / 问题单链接)—— 未填写即视为未通过。
|
||||
当前状态:**未执行**(2026-09-11,开发环境无法进行真实 IME 走查)。
|
||||
|
||||
### 12.7 编辑区高度(2026-09-11,用户要求)
|
||||
|
||||
**改动**:编辑卡默认吃满视口剩余高度,多余的滚动交给编辑器自身(`.cm-scroller`),
|
||||
页面不再随正文长度增长。此前 `.pn-ae-body-wrap` 只有 `min-height: 420px`、高度随内容撑开,
|
||||
导致两个极端:新建空文档时编辑区只有 420px;3000 行长文时页面高达 8.4 万像素、只能整页滚动。
|
||||
|
||||
| 位置 | 改动 |
|
||||
| --- | --- |
|
||||
| `index.css` `.pn-ae-grid` | `height: calc(100vh - 110px)`(topbar 62 + content padding 24×2)、`min-height: 520px`;≤1023px 用 `calc(100vh - 102px)`;≤900px(堆叠布局)回落 `height: auto` |
|
||||
| `index.css` `.pn-ae-main` / `.ant-card-body` | 拉成纵向 flex,把剩余高度让给编辑区 |
|
||||
| `index.css` `.pn-ae-body-wrap` | `flex: 1 1 auto; min-height: 280px; overflow: hidden` |
|
||||
| `Editor.tsx` | CodeMirror 内联 `minHeight` 420 → 240(允许在矮视口下收缩,不再撑破容器) |
|
||||
|
||||
**实测**(1280×720 视口):
|
||||
|
||||
| 场景 | 结果 |
|
||||
| --- | --- |
|
||||
| 新建空文档 | 编辑卡 **610px**(= 100vh−110),编辑区 **379px**;页面 scrollHeight **721** ≈ 一屏,页面不滚动 |
|
||||
| 3000 行长文 | 编辑卡仍 610px,`.cm-scroller` clientHeight 379 / scrollHeight **85087**(`overflow: auto`)→ 编辑器内部滚动;页面 scrollHeight 保持 **721**,`window.scrollY` 恒为 0 |
|
||||
| 内部滚动到中段 | 渲染行(约 1520–1532)全部按渲染态呈现;光标所在块(文档第 1 行)在滚动 4.2 万像素后仍带 `cm-md-h2` 装饰(P3-2 修复点在内部滚动布局下依然成立) |
|
||||
| 窄屏 480×800(≤900px 断点) | 单列堆叠、`height: auto`,编辑区 280px(min-height 兜底),页面正常滚动 |
|
||||
|
||||
**未纳入本次改动**:编辑区最大宽度(当前随卡片宽度全宽)、粘性工具栏、全屏/专注模式。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 附录 A 依赖与 API 核实(2026-09-10,实测已装版本)
|
||||
|
||||
| 依赖 | 版本 | 用到的 API | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `@codemirror/view` | 6.43.11 | `Decoration.replace/mark/widget/line`、`EditorView.atomicRanges`、`view.composing`、`EditorView.decorations` | 已核实 |
|
||||
| `@codemirror/state` | 6.7.4 | `StateField`、`Compartment` | 已核实 |
|
||||
| `@codemirror/language` | 6.12.4 | `syntaxTree`、`ensureSyntaxTree`、`foldable` | 已核实 |
|
||||
| `@codemirror/lang-markdown` | 6.5.2 | `markdownLanguage`(GFM 语言)、`GFM` | 已核实 |
|
||||
| `@lezer/markdown` | — | `Table` / `Task` / `Strikethrough` 节点 | 已核实 |
|
||||
|
||||
**结论:本次改造零新增 npm 依赖。**
|
||||
|
||||
## 附录 B 参考资料
|
||||
|
||||
- Typora:[Markdown Reference](https://support.typora.io/Markdown-Reference/)("expand around cursor" 官方描述)、[DOM 证据 typora-issues#6629](https://github.com/typora/typora-issues/issues/6629)、性能 [#6542](https://github.com/typora/typora-issues/issues/6542) / [#6551](https://github.com/typora/typora-issues/issues/6551)、IME [#6554](https://github.com/typora/typora-issues/issues/6554)
|
||||
- Obsidian:[Decorations API](https://docs.obsidian.md/Plugins/Editor/Decorations)、[Views and editing mode](https://help.obsidian.md/Editing+and+formatting/Views+and+editing+mode)
|
||||
- CodeMirror 6:[Decoration example](https://codemirror.net/examples/decoration/)、[Reference](https://codemirror.net/docs/ref/)、相关 issue:#1654 / #1650 / #1684 / #1688 / #1658 / #1406 / #979 / #575 / #625
|
||||
- 对照编辑器:[Vditor](https://github.com/Vanessa219/vditor)、[HyperMD](https://github.com/laobubu/HyperMD)、[Milkdown](https://milkdown.dev)、[Tiptap Markdown](https://tiptap.dev/docs/editor/markdown)、[Toast UI Editor](https://github.com/nhn/tui.editor)、[ByteMD](https://github.com/bytedance/byteMD)
|
||||
- 项目内:`docs/design.md` §8.2 / §8.1、`docs/decisions.md` D5 / D26、`docs/review-round1.md` 第 18 项
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# 发布产物构建:交叉编译全部目标平台 → dist/,打包并生成 SHA256SUMS。
|
||||
# 本地 `make dist` 与 Gitea Actions 发版工作流共用本脚本,保证产物一致。
|
||||
#
|
||||
# 用法: scripts/build-release.sh [版本号]
|
||||
# 版本号缺省取 `git describe --tags --always`(无 tag 时回退 dev)。
|
||||
# 前置条件:internal/webui/dist 已就绪(make web sync-assets)。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="${1:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo none)"
|
||||
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.buildDate=$DATE"
|
||||
|
||||
# 纯静态(CGO_ENABLED=0,见 Makefile),单机即可交叉编译全部目标。
|
||||
# 不含 windows:internal/store 对 syscall.Umask 等 Unix API 有硬依赖(备份权限加固)。
|
||||
TARGETS=(
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
darwin/amd64
|
||||
darwin/arm64
|
||||
)
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p dist
|
||||
|
||||
for t in "${TARGETS[@]}"; do
|
||||
os="${t%/*}" arch="${t#*/}"
|
||||
name="pn-${VERSION}-${os}-${arch}"
|
||||
echo "==> 构建 $name"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o "dist/$name" ./cmd/pn
|
||||
# tar.gz 保留可执行位
|
||||
(cd dist && tar czf "$name.tar.gz" "$name" && rm "$name")
|
||||
done
|
||||
|
||||
# 生成校验和(Linux 为 sha256sum,macOS 回退 shasum)
|
||||
echo "==> 生成 SHA256SUMS"
|
||||
(
|
||||
cd dist
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- ./* > SHA256SUMS
|
||||
else
|
||||
shasum -a 256 -- ./* > SHA256SUMS
|
||||
fi
|
||||
)
|
||||
|
||||
echo "==> 完成:dist/"
|
||||
ls -lh dist/
|
||||
Generated
+1
@@ -24,6 +24,7 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { useTheme } from '../lib/theme'
|
||||
import { livePreview, livePreviewCompartment } from '../editor/livePreview'
|
||||
|
||||
/**
|
||||
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。
|
||||
* Editor(编辑态,§8.2):CodeMirror 6 即时渲染(Typora 式)+ 图片粘贴/拖拽上传。
|
||||
* 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入
|
||||
* ``。
|
||||
* ``,插入后由装饰层立刻渲染为图片。
|
||||
*
|
||||
* sourceMode=true 时卸载全部装饰,退回纯源码编辑(逃生舱,方案 §4.7)。
|
||||
*/
|
||||
|
||||
interface UploadResult {
|
||||
@@ -34,24 +37,40 @@ export default function Editor({
|
||||
onChange,
|
||||
onUploadStart,
|
||||
onUploadEnd,
|
||||
sourceMode = false,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onUploadStart?: (name: string) => void
|
||||
onUploadEnd?: (err: string | null) => void
|
||||
sourceMode?: boolean
|
||||
}) {
|
||||
const viewRef = useRef<{ view?: EditorView } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const { mode } = useTheme()
|
||||
const appliedSourceMode = useRef<boolean | null>(null)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
markdown({ base: markdownLanguage }),
|
||||
EditorView.lineWrapping,
|
||||
// 初始值即决定首次挂载是否带装饰;后续切换走 useEffect 的 reconfigure
|
||||
livePreviewCompartment.of(sourceMode ? [] : livePreview()),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
// 源码模式 O(1) 切换:整体挂载/卸载装饰扩展(方案 §4.7)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current?.view
|
||||
if (!view || appliedSourceMode.current === null || appliedSourceMode.current === sourceMode) return
|
||||
appliedSourceMode.current = sourceMode
|
||||
view.dispatch({
|
||||
effects: livePreviewCompartment.reconfigure(sourceMode ? [] : livePreview()),
|
||||
})
|
||||
}, [sourceMode])
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(text: string) => {
|
||||
const wrapper = viewRef.current
|
||||
@@ -89,7 +108,7 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={dragOver ? 'pn-cm-drag' : undefined}
|
||||
className={[sourceMode ? '' : 'pn-cm-live', dragOver ? 'pn-cm-drag' : ''].filter(Boolean).join(' ') || undefined}
|
||||
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -109,9 +128,10 @@ export default function Editor({
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }}
|
||||
theme={isDarkMode(mode) ? 'dark' : 'light'}
|
||||
height="100%"
|
||||
style={{ height: '100%', minHeight: 420 }}
|
||||
style={{ height: '100%', minHeight: 240 }}
|
||||
onCreateEditor={(view) => {
|
||||
viewRef.current = { view }
|
||||
appliedSourceMode.current = sourceMode
|
||||
}}
|
||||
onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const files = event.clipboardData?.files
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Spec → CodeMirror 装饰集(方案 §4.2 / §4.3 / §4.4)。
|
||||
//
|
||||
// 与 livePreview.ts 分离:本模块只做「纯转换」,输入是 EditorState + 可见区间,
|
||||
// 输出 DecorationSet / 原子区间集,便于单测(不需要真实 view)。
|
||||
|
||||
import { RangeSet } from '@codemirror/state'
|
||||
import type { EditorState, Range } from '@codemirror/state'
|
||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||
import type { Tree } from '@lezer/common'
|
||||
import { Decoration } from '@codemirror/view'
|
||||
import type { DecorationSet } from '@codemirror/view'
|
||||
import { collectSpecs, type Spec } from './syntax'
|
||||
import { activeLineRanges, applyReveal, selectionEndpointLines } from './reveal'
|
||||
import {
|
||||
BulletWidget,
|
||||
FenceLangWidget,
|
||||
HrWidget,
|
||||
ImageWidget,
|
||||
QuoteBarWidget,
|
||||
TaskWidget,
|
||||
} from './widgets'
|
||||
|
||||
/**
|
||||
* 图片 src 白名单(方案 §6):仅允许站内图片接口与内联 data:image。
|
||||
* 不匹配则整条 image spec 被丢弃 → 退回显示源码文本,不产出 <img>。
|
||||
*/
|
||||
export const IMAGE_SRC_ALLOW = /^(?:\/api\/images\/|data:image\/(?:png|jpe?g|gif|webp|avif);base64,)/i
|
||||
|
||||
export interface LiveDecorations {
|
||||
decorations: DecorationSet
|
||||
atomic: RangeSet<Decoration>
|
||||
}
|
||||
|
||||
function specKey(s: Spec): string {
|
||||
return [s.kind, s.from, s.to, 'line' in s ? s.line : '', 'cls' in s ? s.cls : ''].join('|')
|
||||
}
|
||||
|
||||
/** 强制补解析可见区间所需时间的上限(毫秒)。 */
|
||||
export const PARSE_BUDGET_MS = 20
|
||||
|
||||
/**
|
||||
* 取一棵至少覆盖 `ranges` 末端的语法树。
|
||||
*
|
||||
* 关键点:lezer 是**惰性增量**解析 —— `syntaxTree(state)` 只给出已完成的部分,
|
||||
* 长文档(实测 3000 行 ≈ 15 万字符时首次仅解析约 3000 字符)未覆盖的区间若直接
|
||||
* 返回空装饰,用户会看到裸 Markdown。这里用 `ensureSyntaxTree` 在有限预算内
|
||||
* 把可见区间补齐;预算内没补完就退回已有树,其余部分保持源码态并等解析推进后
|
||||
* 由 `livePreview` 的「树对象变化」触发自动补装饰(不会永久缺失)。
|
||||
*/
|
||||
export function visibleSyntaxTree(
|
||||
state: EditorState,
|
||||
ranges: readonly { from: number; to: number }[],
|
||||
timeoutMs = PARSE_BUDGET_MS,
|
||||
): Tree {
|
||||
let upto = 0
|
||||
for (const r of ranges) if (r.to > upto) upto = r.to
|
||||
if (upto <= 0) return syntaxTree(state)
|
||||
return ensureSyntaxTree(state, upto, timeoutMs) ?? syntaxTree(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集可见区间内的语法 → 应用 reveal → 生成装饰集。
|
||||
* 只遍历 visibleRanges(外加光标所在行),不整篇扫描(方案 §7.4)。
|
||||
*
|
||||
* 为什么额外加选区端点行:CodeMirror 会把选区所在的块单独渲染出来(哪怕它
|
||||
* 在视口之外,用 `cm-gap` 占位)。若只装饰 visibleRanges,光标行在远距离滚动
|
||||
* 后会以裸源码出现。补上这几行成本可忽略,却让「凡渲染出来的行都有装饰」成立。
|
||||
* 注意只取**端点**行 —— 选区跨越多行时 CM 也只渲染端点块,全量取会让 Cmd+A
|
||||
* 在长文档上退化成全文档装饰构建。
|
||||
*
|
||||
* @param reveal 编辑器是否处于聚焦态;未聚焦时整篇按渲染态呈现(决策 E11)
|
||||
*/
|
||||
export function buildDecorations(
|
||||
state: EditorState,
|
||||
visibleRanges: readonly { from: number; to: number }[],
|
||||
reveal: boolean,
|
||||
tree?: Tree,
|
||||
): LiveDecorations {
|
||||
const selectionLines = activeLineRanges(state)
|
||||
const collectRanges: { from: number; to: number }[] = [...visibleRanges]
|
||||
for (const line of selectionEndpointLines(state)) {
|
||||
if (!collectRanges.some((r) => r.from <= line.from && r.to >= line.to)) collectRanges.push(line)
|
||||
}
|
||||
const syntax = tree ?? visibleSyntaxTree(state, collectRanges)
|
||||
|
||||
const raw: Spec[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const range of collectRanges) {
|
||||
// 语法树可能只覆盖文档前段:越界部分不采集(保持源码态,等解析推进后补齐)
|
||||
const to = Math.min(range.to, syntax.length)
|
||||
if (to <= range.from) continue
|
||||
for (const spec of collectSpecs(syntax, state.doc, range.from, to)) {
|
||||
const key = specKey(spec)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
raw.push(spec)
|
||||
}
|
||||
}
|
||||
|
||||
const specs = reveal ? applyReveal(raw, selectionLines) : raw
|
||||
|
||||
const ranges: Range<Decoration>[] = []
|
||||
const atomic: Range<Decoration>[] = []
|
||||
|
||||
for (const spec of specs) {
|
||||
switch (spec.kind) {
|
||||
case 'line':
|
||||
ranges.push(Decoration.line({ class: spec.cls }).range(spec.from))
|
||||
break
|
||||
case 'mark':
|
||||
ranges.push(Decoration.mark({ class: spec.cls }).range(spec.from, spec.to))
|
||||
break
|
||||
case 'hide': {
|
||||
const deco = Decoration.replace({})
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'bullet': {
|
||||
const deco = Decoration.replace({ widget: new BulletWidget(spec.depth) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'quote': {
|
||||
const deco = Decoration.replace({ widget: new QuoteBarWidget(spec.depth) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'hr': {
|
||||
const deco = Decoration.replace({ widget: new HrWidget() })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'image': {
|
||||
if (!IMAGE_SRC_ALLOW.test(spec.src)) break // 非白名单 → 退回源码文本
|
||||
const deco = Decoration.replace({ widget: new ImageWidget(spec.src, spec.alt) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'task': {
|
||||
const deco = Decoration.replace({ widget: new TaskWidget(spec.from, spec.to, spec.checked) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'fenceLang': {
|
||||
const deco = Decoration.replace({ widget: new FenceLangWidget(spec.lang) })
|
||||
const r = deco.range(spec.from, spec.to)
|
||||
ranges.push(r)
|
||||
atomic.push(r)
|
||||
break
|
||||
}
|
||||
case 'tableMark': {
|
||||
ranges.push(Decoration.mark({ class: 'cm-md-table-mark' }).range(spec.from, spec.to))
|
||||
if (spec.delimiter) ranges.push(Decoration.mark({ class: 'cm-md-table-delim' }).range(spec.from, spec.to))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: Decoration.set(ranges, true),
|
||||
atomic: RangeSet.of(atomic, true),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 即时渲染扩展入口(方案 §3.1 / §4.7)。
|
||||
//
|
||||
// - ViewPlugin:装饰计算只跑可见区间;selection 变化即重算(行粒度 reveal);
|
||||
// - EditorView.atomicRanges:隐藏的纯标记区间原子化,方向键整段跳过;
|
||||
// - livePreviewCompartment:源码模式开关整体挂载/卸载,O(1) 且无残留装饰。
|
||||
//
|
||||
// IME(方案 §7.1):视图处于组合输入(view.composing)且文档变化时,
|
||||
// 不做重算,只把旧装饰按变更映射 —— 组合范围内绝不替换 DOM。
|
||||
|
||||
import { Compartment, RangeSet } from '@codemirror/state'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import type { DecorationSet, PluginValue, ViewUpdate } from '@codemirror/view'
|
||||
import { buildDecorations } from './decorations'
|
||||
|
||||
class LivePreviewPlugin implements PluginValue {
|
||||
decorations: DecorationSet = Decoration.none
|
||||
atomic: RangeSet<Decoration> = RangeSet.empty
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.build(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged && update.view.composing) {
|
||||
// 组合输入期间冻结:只映射位置,不重建装饰(§7.1)
|
||||
this.decorations = this.decorations.map(update.changes)
|
||||
this.atomic = this.atomic.map(update.changes)
|
||||
return
|
||||
}
|
||||
// 语法树对象换代 = 后台解析推进:必须重建,否则未解析区间的装饰会一直缺失
|
||||
const treeAdvanced = syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
update.viewportChanged ||
|
||||
update.focusChanged ||
|
||||
treeAdvanced
|
||||
) {
|
||||
this.build(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
private build(view: EditorView): void {
|
||||
// 未聚焦时整篇按渲染态呈现(决策 E11):编辑器失焦 = 只读观感,
|
||||
// 避免「首行因光标停在行首而永远显示 #」的观感缺陷。
|
||||
const { decorations, atomic } = buildDecorations(
|
||||
view.state,
|
||||
view.visibleRanges,
|
||||
view.hasFocus,
|
||||
)
|
||||
this.decorations = decorations
|
||||
this.atomic = atomic
|
||||
}
|
||||
}
|
||||
|
||||
export const livePreviewPlugin = ViewPlugin.fromClass(LivePreviewPlugin, {
|
||||
decorations: (value) => value.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? RangeSet.empty),
|
||||
})
|
||||
|
||||
/** 源码模式开关的 compartment;`empty` 表示回到纯源码编辑。 */
|
||||
export const livePreviewCompartment = new Compartment()
|
||||
|
||||
/** 组装即时渲染扩展集。 */
|
||||
export function livePreview(): Extension {
|
||||
return [livePreviewPlugin]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 光标处展开(方案 §4.5)。
|
||||
//
|
||||
// 实现为纯函数:装饰集 = f(语法树, selection)。v1 取行粒度(决策 E6):
|
||||
// 光标/选区触及的行,其语法标记整体展开为源码。行粒度对 IME 最安全 ——
|
||||
// 组字所在行永远是展开态,组合过程中不会被 replace 掉 DOM。
|
||||
//
|
||||
// span 级展开(Typora 的「光标进入行内节点才展开」)列入方案 §10.2。
|
||||
|
||||
import type { EditorState } from '@codemirror/state'
|
||||
import type { Spec } from './syntax'
|
||||
|
||||
export interface Range {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/** 选区(全部 range,非仅 main)所触及的整行区间集合(升序、互不相交)。 */
|
||||
export function activeLineRanges(state: EditorState): Range[] {
|
||||
const out: Range[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
const start = state.doc.lineAt(r.from)
|
||||
const end = state.doc.lineAt(r.to)
|
||||
for (let n = start.number; n <= end.number; n++) {
|
||||
if (seen.has(n)) continue
|
||||
seen.add(n)
|
||||
const line = state.doc.line(n)
|
||||
out.push({ from: line.from, to: line.to })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 选区**端点**所在的整行(CM 只把选区端点所在的块渲染在视口之外,
|
||||
* 不会渲染整个选区跨越的每一行)。用于决定「除可见区间外还要装饰哪些行」,
|
||||
* 避免 Cmd+A 这种整篇选区触发全文档装饰构建。
|
||||
*/
|
||||
export function selectionEndpointLines(state: EditorState): Range[] {
|
||||
const out: Range[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const r of state.selection.ranges) {
|
||||
for (const pos of [r.from, r.to]) {
|
||||
const line = state.doc.lineAt(pos)
|
||||
if (seen.has(line.number)) continue
|
||||
seen.add(line.number)
|
||||
out.push({ from: line.from, to: line.to })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** [from, to) 是否与任一展开区间相交。零长区间不与任何行相交。 */
|
||||
export function intersectsAny(ranges: readonly Range[], from: number, to: number): boolean {
|
||||
if (to <= from) return false
|
||||
for (const r of ranges) {
|
||||
if (from <= r.to && r.from <= to) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 把区间排序并合并,得到互不相交的升序区间,供二分查询使用。 */
|
||||
function mergeRanges(ranges: readonly Range[]): Range[] {
|
||||
if (ranges.length < 2) return ranges.map((r) => ({ from: r.from, to: r.to }))
|
||||
const sorted = ranges
|
||||
.map((r) => ({ from: r.from, to: r.to }))
|
||||
.sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
const out: Range[] = [sorted[0]]
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = out[out.length - 1]
|
||||
if (sorted[i].from <= last.to) last.to = Math.max(last.to, sorted[i].to)
|
||||
else out.push(sorted[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 消除 O(装饰数 × 展开区间数):展开区间先合并再二分。
|
||||
* 整篇选区(Cmd+A)在长文档下会产生数千个展开区间,线性扫描会让单帧成本失控。
|
||||
*/
|
||||
export function intersectsMerged(sortedMerged: readonly Range[], from: number, to: number): boolean {
|
||||
if (to <= from) return false
|
||||
let lo = 0
|
||||
let hi = sortedMerged.length - 1
|
||||
let idx = -1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (sortedMerged[mid].from <= to) {
|
||||
idx = mid
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return idx >= 0 && sortedMerged[idx].to >= from
|
||||
}
|
||||
|
||||
/**
|
||||
* 丢掉落在展开区间内的「标记替代物」,保留样式类装饰。
|
||||
* 结果仍是文档顺序(collectSpecs 输出即有序)。
|
||||
*/
|
||||
export function applyReveal(specs: readonly Spec[], ranges: readonly Range[]): Spec[] {
|
||||
if (ranges.length === 0) return specs.slice()
|
||||
const merged = mergeRanges(ranges)
|
||||
return specs.filter((s) => !(s.reveal && intersectsMerged(merged, s.from, s.to)))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// 即时渲染(Typora 式)语法采集层(方案 §4.1)。
|
||||
//
|
||||
// 职责:把 lezer Markdown 语法树翻译成「与 CodeMirror 解耦」的装饰描述(Spec)。
|
||||
// 纯函数、零 DOM、零 CM view 依赖,便于 vitest 直接断言区间。
|
||||
//
|
||||
// 设计约束:
|
||||
// - 只认语法树节点,不写正则(方案 §4.1);
|
||||
// - 采集结果不做 reveal 过滤(reveal 是 selection 的函数,见 reveal.ts),
|
||||
// 由 decorations.ts 在构建阶段调用 applyReveal —— 这样「识别」与「展开」
|
||||
// 可以分别单测;
|
||||
// - 任何 from/to 都不跨越行边界(CM6 对 replace 跨行的硬性约束,方案 §4.2)。
|
||||
|
||||
import type { SyntaxNode, Tree } from '@lezer/common'
|
||||
import type { Text } from '@codemirror/state'
|
||||
|
||||
/** 装饰描述类型(kind 决定 decorations.ts 里用哪种 Decoration)。 */
|
||||
export type SpecKind =
|
||||
| 'hide' // 纯定界符,replace 为空 + 原子
|
||||
| 'mark' // 行内样式,Decoration.mark
|
||||
| 'line' // 整行样式,Decoration.line
|
||||
| 'bullet' // 无序列表圆点 widget(§5.1)
|
||||
| 'quote' // 引用竖线 widget(§5.3)
|
||||
| 'hr' // 水平线 widget(§5.4)
|
||||
| 'image' // 图片 widget(§5.4)
|
||||
| 'task' // 任务复选框 widget(§5.4)
|
||||
| 'fenceLang' // 代码围栏语言标签 widget(§5.4)
|
||||
| 'tableMark' // 表格定界符淡化(§4.4)
|
||||
|
||||
interface BaseSpec {
|
||||
from: number
|
||||
to: number
|
||||
/**
|
||||
* 是否属于「语法标记替代物」:为 true 时,光标所在行会撤销该装饰、展开源码
|
||||
* (方案 §4.5 行粒度 reveal)。样式类(mark/line)恒为 false。
|
||||
*/
|
||||
reveal: boolean
|
||||
}
|
||||
|
||||
export interface HideSpec extends BaseSpec {
|
||||
kind: 'hide'
|
||||
}
|
||||
export interface MarkSpec extends BaseSpec {
|
||||
kind: 'mark'
|
||||
cls: string
|
||||
}
|
||||
export interface LineSpec extends BaseSpec {
|
||||
kind: 'line'
|
||||
line: number
|
||||
cls: string
|
||||
}
|
||||
export interface BulletSpec extends BaseSpec {
|
||||
kind: 'bullet'
|
||||
depth: number
|
||||
}
|
||||
export interface QuoteSpec extends BaseSpec {
|
||||
kind: 'quote'
|
||||
depth: number
|
||||
}
|
||||
export interface HrSpec extends BaseSpec {
|
||||
kind: 'hr'
|
||||
}
|
||||
export interface ImageSpec extends BaseSpec {
|
||||
kind: 'image'
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
export interface TaskSpec extends BaseSpec {
|
||||
kind: 'task'
|
||||
checked: boolean
|
||||
}
|
||||
export interface FenceLangSpec extends BaseSpec {
|
||||
kind: 'fenceLang'
|
||||
lang: string
|
||||
}
|
||||
export interface TableMarkSpec extends BaseSpec {
|
||||
kind: 'tableMark'
|
||||
/** 是否为分隔行(`| --- |`),用于「淡化」与「更淡」两级 */
|
||||
delimiter: boolean
|
||||
}
|
||||
export type Spec =
|
||||
| HideSpec
|
||||
| MarkSpec
|
||||
| LineSpec
|
||||
| BulletSpec
|
||||
| QuoteSpec
|
||||
| HrSpec
|
||||
| ImageSpec
|
||||
| TaskSpec
|
||||
| FenceLangSpec
|
||||
| TableMarkSpec
|
||||
|
||||
const HEADING_RE = /^ATXHeading([1-6])$/
|
||||
|
||||
/** 行内样式类名(对齐 .pn-note-body 的阅读态,方案 §4.3)。 */
|
||||
export const CLS_STRONG = 'cm-md-strong'
|
||||
export const CLS_EM = 'cm-md-em'
|
||||
export const CLS_DEL = 'cm-md-del'
|
||||
export const CLS_CODE = 'cm-md-code'
|
||||
export const CLS_LINK = 'cm-md-link'
|
||||
export const CLS_TABLE = 'cm-md-table'
|
||||
export const CLS_FENCED = 'cm-md-fenced'
|
||||
export const CLS_QUOTE_LINE = 'cm-md-quote'
|
||||
export const CLS_OL_MARK = 'cm-md-ol-mark'
|
||||
|
||||
/** 折行到下一个非空白位置(用于把 `#` / `-` 后的空格一并吃掉)。 */
|
||||
function afterSpaces(doc: Text, pos: number, limit: number): number {
|
||||
let p = pos
|
||||
while (p < limit && doc.sliceString(p, p + 1) === ' ') p++
|
||||
return p
|
||||
}
|
||||
|
||||
/** 向上找最近的同名祖先。 */
|
||||
function nearestAncestor(node: SyntaxNode, name: string): SyntaxNode | null {
|
||||
for (let p = node.parent; p; p = p.parent) if (p.name === name) return p
|
||||
return null
|
||||
}
|
||||
|
||||
/** 列表嵌套深度 = 祖先链中 BulletList / OrderedList 的个数(方案 §5.1)。 */
|
||||
function listDepth(node: SyntaxNode): number {
|
||||
let d = 0
|
||||
for (let p = node.parent; p; p = p.parent) {
|
||||
if (p.name === 'BulletList' || p.name === 'OrderedList') d++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** 引用嵌套深度 = 祖先链中 Blockquote 的个数。 */
|
||||
function quoteDepth(node: SyntaxNode): number {
|
||||
let d = 0
|
||||
for (let p = node.parent; p; p = p.parent) if (p.name === 'Blockquote') d++
|
||||
return d
|
||||
}
|
||||
|
||||
/** 该列表项是否为任务项(`- [ ] x`)—— 任务项不画圆点,只画复选框。 */
|
||||
function isTaskItem(node: SyntaxNode): boolean {
|
||||
const li = nearestAncestor(node, 'ListItem')
|
||||
return !!li?.getChild('Task')
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集 [from, to) 区间内所有装饰描述。区间通常取 `view.visibleRanges`,
|
||||
* 保证只做视口内的工作(方案 §7.4)。
|
||||
*/
|
||||
export function collectSpecs(tree: Tree, doc: Text, from: number, to: number): Spec[] {
|
||||
const specs: Spec[] = []
|
||||
const push = (s: Spec) => {
|
||||
if (s.kind === 'line' || s.to > s.from) specs.push(s)
|
||||
}
|
||||
const hide = (f: number, t: number) => push({ kind: 'hide', from: f, to: t, reveal: true })
|
||||
const mark = (f: number, t: number, cls: string) => push({ kind: 'mark', from: f, to: t, cls, reveal: false })
|
||||
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
const name = node.name
|
||||
const nodeFrom = node.from
|
||||
const nodeTo = node.to
|
||||
|
||||
const heading = HEADING_RE.exec(name)
|
||||
if (heading) {
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: `cm-md-h${heading[1]}`, reveal: false })
|
||||
const hm = node.node.getChild('HeaderMark')
|
||||
if (hm) hide(hm.from, afterSpaces(doc, hm.to, line.to))
|
||||
return
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'StrongEmphasis':
|
||||
case 'Emphasis':
|
||||
case 'Strikethrough': {
|
||||
const markName = name === 'Strikethrough' ? 'StrikethroughMark' : 'EmphasisMark'
|
||||
const marks = node.node.getChildren(markName)
|
||||
if (marks.length >= 2) {
|
||||
const open = marks[0]
|
||||
const close = marks[marks.length - 1]
|
||||
const cls = name === 'StrongEmphasis' ? CLS_STRONG : name === 'Emphasis' ? CLS_EM : CLS_DEL
|
||||
hide(open.from, open.to)
|
||||
hide(close.from, close.to)
|
||||
mark(open.to, close.from, cls)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'InlineCode': {
|
||||
// 行内代码内容为字面量,不再向下解析
|
||||
const marks = node.node.getChildren('CodeMark')
|
||||
if (marks.length >= 2) {
|
||||
hide(marks[0].from, marks[0].to)
|
||||
hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||
mark(marks[0].to, marks[marks.length - 1].from, CLS_CODE)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case 'Link': {
|
||||
const marks = node.node.getChildren('LinkMark')
|
||||
if (marks.length >= 2) {
|
||||
hide(marks[0].from, marks[0].to)
|
||||
mark(marks[0].to, marks[1].from, CLS_LINK)
|
||||
// `](url)` 整段隐藏(URL 不产出 <a>,v1 只做文本样式,方案 §6)
|
||||
hide(marks[1].from, nodeTo)
|
||||
}
|
||||
// 继续下钻:链接内可嵌图片
|
||||
return
|
||||
}
|
||||
|
||||
case 'Image': {
|
||||
const url = node.node.getChild('URL')
|
||||
const imgMarks = node.node.getChildren('LinkMark')
|
||||
const alt = imgMarks.length >= 2 ? doc.sliceString(imgMarks[0].to, imgMarks[1].from) : ''
|
||||
push({
|
||||
kind: 'image',
|
||||
from: nodeFrom,
|
||||
to: nodeTo,
|
||||
reveal: false,
|
||||
alt,
|
||||
src: url ? doc.sliceString(url.from, url.to) : '',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
case 'HorizontalRule':
|
||||
push({ kind: 'hr', from: nodeFrom, to: nodeTo, reveal: true })
|
||||
return false
|
||||
|
||||
case 'QuoteMark': {
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
// 行级样式与竖线 widget 分工:widget 承接标记,行类把正文调成阅读态的弱化色
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: CLS_QUOTE_LINE, reveal: false })
|
||||
push({ kind: 'quote', from: nodeFrom, to: nodeTo, reveal: true, depth: quoteDepth(node.node) })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'ListMark': {
|
||||
const list = nearestAncestor(node.node, 'BulletList') ?? nearestAncestor(node.node, 'OrderedList')
|
||||
const line = doc.lineAt(nodeFrom)
|
||||
const end = afterSpaces(doc, nodeTo, line.to)
|
||||
if (list?.name === 'BulletList') {
|
||||
if (isTaskItem(node.node)) {
|
||||
hide(nodeFrom, end) // 任务项:标记让位给复选框
|
||||
} else {
|
||||
push({ kind: 'bullet', from: nodeFrom, to: end, reveal: true, depth: listDepth(node.node) })
|
||||
}
|
||||
} else {
|
||||
// 有序列表:保留数字(携带序号语义,方案 §5.2 / 决策 E3),仅上样式
|
||||
mark(nodeFrom, nodeTo, CLS_OL_MARK)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case 'TaskMarker': {
|
||||
// 复选框始终渲染(不参与 reveal),点击即改写源文本(方案 §5.4)
|
||||
const checked = doc.sliceString(nodeFrom, nodeTo).toLowerCase().includes('x')
|
||||
push({ kind: 'task', from: nodeFrom, to: nodeTo, reveal: false, checked })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'FencedCode': {
|
||||
const marks = node.node.getChildren('CodeMark')
|
||||
const info = node.node.getChild('CodeInfo')
|
||||
const startLine = doc.lineAt(nodeFrom)
|
||||
const endLine = doc.lineAt(nodeTo)
|
||||
if (marks.length >= 1) {
|
||||
const open = marks[0]
|
||||
const end = info ? info.to : open.to
|
||||
if (info) {
|
||||
push({ kind: 'fenceLang', from: open.from, to: end, reveal: true, lang: doc.sliceString(info.from, info.to) })
|
||||
} else {
|
||||
hide(open.from, end)
|
||||
}
|
||||
}
|
||||
if (marks.length >= 2) hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||
const line = doc.line(n)
|
||||
const cls = [
|
||||
CLS_FENCED,
|
||||
n === startLine.number ? 'cm-md-fenced-first' : '',
|
||||
n === endLine.number ? 'cm-md-fenced-last' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: n, cls, reveal: false })
|
||||
}
|
||||
return false // 代码块内不解析行内语法
|
||||
}
|
||||
|
||||
case 'Table': {
|
||||
const startLine = doc.lineAt(nodeFrom)
|
||||
const endLine = doc.lineAt(nodeTo)
|
||||
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||
const line = doc.line(n)
|
||||
push({ kind: 'line', from: line.from, to: line.to, line: n, cls: CLS_TABLE, reveal: false })
|
||||
}
|
||||
return // 下钻以标记 TableDelimiter
|
||||
}
|
||||
|
||||
case 'TableDelimiter': {
|
||||
// 分隔行(`| --- |`)比普通竖线更淡,用于区分表头分隔
|
||||
const text = doc.sliceString(nodeFrom, nodeTo)
|
||||
push({ kind: 'tableMark', from: nodeFrom, to: nodeTo, reveal: false, delimiter: text.includes('-') })
|
||||
return false
|
||||
}
|
||||
|
||||
case 'TableCell':
|
||||
// 表格为「源码对齐」呈现(决策 E5),单元格内不隐藏语法
|
||||
return false
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return specs
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 即时渲染的 widget 集合(方案 §5)。
|
||||
//
|
||||
// 硬性约束(§5.5):
|
||||
// - 一律 document.createElement / textContent / setAttribute,禁止 innerHTML;
|
||||
// - widget DOM 不接受任何用户 HTML,只消费语法树里解析出的「文本/白名单 URL」;
|
||||
// - 除任务复选框外均 aria-hidden="true"(它们是语法标记的视觉替代物);
|
||||
// - widget 不改变文档内容与选区语义(任务框例外:它显式改写源文本)。
|
||||
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
|
||||
const BULLET_CHARS = ['•', '◦', '▪']
|
||||
|
||||
/** 无序列表圆点(§5.1):按嵌套深度取 • / ◦ / ▪。 */
|
||||
export class BulletWidget extends WidgetType {
|
||||
constructor(readonly depth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: BulletWidget): boolean {
|
||||
return other.depth === this.depth
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-bullet'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.textContent = BULLET_CHARS[Math.min(this.depth - 1, BULLET_CHARS.length - 1)] ?? BULLET_CHARS[0]
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 引用竖线(§5.3):CSS 画 2px 竖线,宽度 = 原标记宽度,嵌套自然叠加。 */
|
||||
export class QuoteBarWidget extends WidgetType {
|
||||
constructor(readonly depth: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: QuoteBarWidget): boolean {
|
||||
return other.depth === this.depth
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-quote-bar'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.dataset.depth = String(this.depth)
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 水平线(§5.4):整行内容替换为一条贯穿的分隔线。 */
|
||||
export class HrWidget extends WidgetType {
|
||||
eq(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-hr'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片(§5.4):src 白名单在 decorations.ts 里已校验,此处只负责 DOM。 */
|
||||
export class ImageWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly src: string,
|
||||
readonly alt: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: ImageWidget): boolean {
|
||||
return other.src === this.src && other.alt === this.alt
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const img = document.createElement('img')
|
||||
img.className = 'cm-md-image'
|
||||
img.src = this.src
|
||||
img.alt = this.alt
|
||||
img.loading = 'lazy'
|
||||
// 图片加载失败(如已删除)时保持占位,不破坏行高
|
||||
img.addEventListener('error', () => {
|
||||
img.classList.add('cm-md-image-error')
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 围栏语言标签(§5.4):` ```js ` → 语言名小标签。 */
|
||||
export class FenceLangWidget extends WidgetType {
|
||||
constructor(readonly lang: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: FenceLangWidget): boolean {
|
||||
return other.lang === this.lang
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-md-fence-lang'
|
||||
span.setAttribute('aria-hidden', 'true')
|
||||
span.textContent = this.lang
|
||||
return span
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务复选框(§5.4):点击直接改写源文本 `[ ]` ↔ `[x]`,真相源仍是 Markdown。
|
||||
* 该 widget 不参与 reveal(复选框始终可见可点,与 Typora 一致)。
|
||||
*/
|
||||
export class TaskWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly from: number,
|
||||
readonly to: number,
|
||||
readonly checked: boolean,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: TaskWidget): boolean {
|
||||
return other.from === this.from && other.to === this.to && other.checked === this.checked
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'checkbox'
|
||||
input.className = 'cm-md-task'
|
||||
input.checked = this.checked
|
||||
input.setAttribute('aria-label', this.checked ? '标记为未完成' : '标记为已完成')
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
// 阻止 CM 把焦点/光标移走,自己处理取反
|
||||
event.preventDefault()
|
||||
view.dispatch({
|
||||
changes: { from: this.from, to: this.to, insert: this.checked ? '[ ]' : '[x]' },
|
||||
})
|
||||
}
|
||||
input.addEventListener('mousedown', onMouseDown)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 复选框要自己处理事件(默认 true 即「编辑器忽略」),无需覆写。 */
|
||||
ignoreEvent(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
+230
-21
@@ -99,6 +99,20 @@
|
||||
--font-serif-display: var(--font-serif);
|
||||
--tracking-tight: -0.012em;
|
||||
|
||||
/* Markdown 排版契约(方案 §4.6):阅读态 .pn-note-body 与编辑态 .cm-md-* 共用,
|
||||
杜绝两份样式漂移。改这里 = 同时改编辑态与公开页。 */
|
||||
--md-body-size: 15px;
|
||||
--md-body-lh: 1.8;
|
||||
--md-h1-size: 26px;
|
||||
--md-h2-size: 24px;
|
||||
--md-h3-size: 19px;
|
||||
--md-h4-size: 16px;
|
||||
--md-heading-lh: 1.35;
|
||||
--md-heading-font: var(--font-serif);
|
||||
--md-code-size: 13px;
|
||||
--md-table-size: 13.5px;
|
||||
--md-img-radius: var(--radius-md);
|
||||
|
||||
/* 圆角 / 阴影 / 间距基数 */
|
||||
--radius: 0.75rem;
|
||||
--radius-sm: 0.375rem;
|
||||
@@ -804,8 +818,8 @@ button.pn-note-back:hover {
|
||||
============================================================ */
|
||||
.pn-note-body {
|
||||
margin-top: 40px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
font-size: var(--md-body-size);
|
||||
line-height: var(--md-body-lh);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.pn-note-body > :first-child {
|
||||
@@ -819,23 +833,23 @@ button.pn-note-back:hover {
|
||||
.pn-note-body h3,
|
||||
.pn-note-body h4 {
|
||||
margin: 40px 0 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-family: var(--md-heading-font);
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
line-height: var(--md-heading-lh);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.pn-note-body h1 {
|
||||
font-size: 26px;
|
||||
font-size: var(--md-h1-size);
|
||||
}
|
||||
.pn-note-body h2 {
|
||||
font-size: 24px;
|
||||
font-size: var(--md-h2-size);
|
||||
}
|
||||
.pn-note-body h3 {
|
||||
font-size: 19px;
|
||||
font-size: var(--md-h3-size);
|
||||
}
|
||||
.pn-note-body h4 {
|
||||
font-size: 16px;
|
||||
font-size: var(--md-h4-size);
|
||||
}
|
||||
.pn-note-body blockquote {
|
||||
margin: 32px 0;
|
||||
@@ -851,7 +865,7 @@ button.pn-note-back:hover {
|
||||
}
|
||||
.pn-note-body code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-size: var(--md-code-size);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
@@ -870,7 +884,7 @@ button.pn-note-back:hover {
|
||||
display: block;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--md-code-size);
|
||||
line-height: 1.7;
|
||||
color: var(--foreground);
|
||||
white-space: pre;
|
||||
@@ -892,7 +906,7 @@ button.pn-note-back:hover {
|
||||
}
|
||||
.pn-note-body img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--md-img-radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pn-note-body hr {
|
||||
@@ -904,7 +918,7 @@ button.pn-note-back:hover {
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
font-size: var(--md-table-size);
|
||||
}
|
||||
.pn-note-body th,
|
||||
.pn-note-body td {
|
||||
@@ -1645,6 +1659,23 @@ button.ant-btn.pn-admin-mobile-act {
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
/* 编辑卡默认吃满视口剩余高度:短文也有开阔的编辑区,长文在编辑器内部滚动
|
||||
而不再把页面撑到几万像素(topbar 62px + content padding 24px×2)。 */
|
||||
height: calc(100vh - 110px);
|
||||
min-height: 520px;
|
||||
}
|
||||
/* 编辑卡(含 antd Card 的 body)拉成纵向 flex,把剩余高度让给编辑区 */
|
||||
.pn-ae-main {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.pn-ae-main > .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
/* 编辑卡与发布卡的 Card 基础样式由 ui/card 承接(gap-0/p-6 等) */
|
||||
/* 提权至 (0,1,1)+:压过 antd .ant-input 的同名字段(cssinjs 无 layer 且后注入)。
|
||||
@@ -1732,16 +1763,11 @@ button.pn-ae-tool svg {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pn-ae-body-wrap {
|
||||
min-height: 420px;
|
||||
/* 吃掉卡片内的剩余高度(滚动由 CodeMirror 的 .cm-scroller 承担) */
|
||||
flex: 1 1 auto;
|
||||
min-height: 280px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.pn-ae-preview {
|
||||
margin-top: 20px;
|
||||
padding: 4px 2px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.pn-ae-preview .pn-note-body {
|
||||
margin-top: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pn-ae-upload-alert {
|
||||
margin-top: 10px;
|
||||
@@ -1795,9 +1821,18 @@ button.pn-ae-tool svg {
|
||||
.ant-divider.pn-ae-divider {
|
||||
margin: 14px 0;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.pn-ae-grid {
|
||||
/* 该断点下 content padding 收紧为 20px */
|
||||
height: calc(100vh - 102px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.pn-ae-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
/* 堆叠布局:回到内容高度,避免编辑区被压扁 */
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.pn-ae-side {
|
||||
position: static;
|
||||
@@ -1975,6 +2010,180 @@ button.pn-ae-tool svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
即时渲染(Typora 式)装饰层 —— 与 .pn-note-body 共用 --md-* 契约
|
||||
仅 .pn-cm-live 包裹时生效;源码模式下整块不生效(§4.7 逃生舱)
|
||||
============================================================ */
|
||||
.pn-cm-live .cm-scroller {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.pn-cm-live .cm-content {
|
||||
font-size: var(--md-body-size);
|
||||
line-height: var(--md-body-lh);
|
||||
padding: 8px 2px 40px;
|
||||
}
|
||||
.pn-cm-live .cm-editor {
|
||||
font-size: var(--md-body-size);
|
||||
}
|
||||
/* Typora 无行号、无活动行高亮(§4.6-4) */
|
||||
.pn-cm-live .cm-gutters {
|
||||
display: none;
|
||||
}
|
||||
.pn-cm-live .cm-activeLine {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* 行内:对齐 .pn-note-body(§4.3) */
|
||||
.cm-md-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cm-md-em {
|
||||
font-style: italic;
|
||||
}
|
||||
.cm-md-del {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-md-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-code-size);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.cm-md-link {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* 块级:标题(§4.4) */
|
||||
.cm-md-h1,
|
||||
.cm-md-h2,
|
||||
.cm-md-h3,
|
||||
.cm-md-h4,
|
||||
.cm-md-h5,
|
||||
.cm-md-h6 {
|
||||
font-family: var(--md-heading-font);
|
||||
font-weight: 400;
|
||||
line-height: var(--md-heading-lh);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.cm-md-h1 {
|
||||
font-size: var(--md-h1-size);
|
||||
}
|
||||
.cm-md-h2 {
|
||||
font-size: var(--md-h2-size);
|
||||
}
|
||||
.cm-md-h3 {
|
||||
font-size: var(--md-h3-size);
|
||||
}
|
||||
.cm-md-h4 {
|
||||
font-size: var(--md-h4-size);
|
||||
}
|
||||
.cm-md-h5 {
|
||||
font-size: var(--md-body-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cm-md-h6 {
|
||||
font-size: var(--md-body-size);
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 块级:引用行(竖线由 widget 承接,§5.3) */
|
||||
.cm-md-quote {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cm-md-quote-bar {
|
||||
display: inline-block;
|
||||
width: 0.6em;
|
||||
height: 1.05em;
|
||||
margin-right: 8px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
vertical-align: -0.12em;
|
||||
}
|
||||
|
||||
/* 块级:无序列表圆点(§5.1) */
|
||||
.cm-md-bullet {
|
||||
display: inline-block;
|
||||
width: 1.15em;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cm-md-ol-mark {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 块级:代码围栏(§4.4) */
|
||||
.cm-md-fenced {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-code-size);
|
||||
line-height: 1.7;
|
||||
background: var(--muted);
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
.cm-md-fenced-first {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.cm-md-fenced-last {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cm-md-fence-lang {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 块级:表格(源码对齐,决策 E5) */
|
||||
.cm-md-table {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--md-table-size);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cm-md-table-mark {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.cm-md-table-delim {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* 块级:图片 / 水平线 / 任务框(§5.4) */
|
||||
.cm-md-image {
|
||||
max-width: 100%;
|
||||
border-radius: var(--md-img-radius);
|
||||
border: 1px solid var(--border);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.cm-md-image-error {
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
min-height: 42px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.cm-md-hr {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cm-md-task {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 2px 0 0;
|
||||
vertical-align: -2px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 动效偏好 ─────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
||||
Bold, Code, Eye, FileCode2, Italic, Link2, List, Quote,
|
||||
Save, Table, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Alert, Badge, Button, Card, DatePicker, Divider, Input, Select, Spin, Switch } from 'antd'
|
||||
@@ -11,7 +11,6 @@ import { api, ApiError } from '../lib/api'
|
||||
import type { NoteItem } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
import { useDocumentTitle } from '../features/site'
|
||||
import MarkdownViewer from '../components/MarkdownViewer'
|
||||
import { AdminTopbar } from '../components/Layout'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { slugPreview } from '../lib/utils'
|
||||
@@ -71,7 +70,7 @@ export default function AdminEdit() {
|
||||
const [form, setForm] = useState<Form>(emptyForm)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [sourceMode, setSourceMode] = useState(false)
|
||||
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
|
||||
const dirtyRef = useRef(false)
|
||||
const slugTouchedRef = useRef(false)
|
||||
@@ -202,10 +201,11 @@ export default function AdminEdit() {
|
||||
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
onClick={() => setShowPreview((v) => !v)}
|
||||
icon={sourceMode ? <Eye size={15} /> : <FileCode2 size={15} />}
|
||||
onClick={() => setSourceMode((v) => !v)}
|
||||
title={sourceMode ? '退出源码模式,回到即时渲染' : '查看/编辑 Markdown 源码(逃生舱)'}
|
||||
>
|
||||
{showPreview ? '隐藏预览' : '预览'}
|
||||
{sourceMode ? '退出源码' : '源码模式'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -257,7 +257,7 @@ export default function AdminEdit() {
|
||||
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
|
||||
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
|
||||
<span className="pn-ae-tool-hint">{sourceMode ? '源码模式 · Markdown' : '即时渲染 · Markdown'}</span>
|
||||
</div>
|
||||
|
||||
<div className="pn-ae-body-wrap">
|
||||
@@ -265,6 +265,7 @@ export default function AdminEdit() {
|
||||
<Editor
|
||||
value={form.content}
|
||||
onChange={(v) => set('content', v)}
|
||||
sourceMode={sourceMode}
|
||||
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
|
||||
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
|
||||
/>
|
||||
@@ -278,12 +279,6 @@ export default function AdminEdit() {
|
||||
message={uploadMsg.text}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<div className="pn-ae-preview">
|
||||
<MarkdownViewer source={form.content} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<aside className="pn-ae-side">
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// P3 加固回归(方案 §7.2 / §7.3 / §7.4、§12.4):
|
||||
// 1) 文首边界:文档以标题/列表/任务/围栏/水平线开头;
|
||||
// 2) 文末边界:文档以水平线/图片/围栏/任务结尾(对应 CM issue #625「文末块 widget 消失」一类的回归面);
|
||||
// 3) 跨原子区间选区:全选替换、跨隐藏标记的选区替换、只选中可见内容时标记不被破坏;
|
||||
// 4) 不变量:原子区间不跨行、有序不重叠、不含可编辑内容、隐藏标记必在原子区间内;
|
||||
// 5) 性能基准:3000 行文档的装饰计算耗时(§7.4 目标 < 16ms/帧)。
|
||||
//
|
||||
// 说明:happy-dom 下 CodeMirror 可以真实创建 EditorView 并渲染装饰 DOM,
|
||||
// 因此本文件用真实 view 断言,而不是只看纯函数输出。
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { deleteCharBackward } from '@codemirror/commands'
|
||||
import { livePreview } from '../src/editor/livePreview'
|
||||
import { collectSpecs } from '../src/editor/syntax'
|
||||
import { buildDecorations, visibleSyntaxTree } from '../src/editor/decorations'
|
||||
|
||||
const views: EditorView[] = []
|
||||
|
||||
function mount(doc: string, anchor = 0, focus = true): EditorView {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor },
|
||||
extensions: [markdown({ base: markdownLanguage }), livePreview()],
|
||||
})
|
||||
const parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
const view = new EditorView({ state, parent })
|
||||
if (focus) view.focus()
|
||||
views.push(view)
|
||||
return view
|
||||
}
|
||||
|
||||
/** 通过公开 facet 读出当前生效的原子区间(升序)。 */
|
||||
function atomicRanges(view: EditorView): Array<[number, number]> {
|
||||
const out: Array<[number, number]> = []
|
||||
for (const fn of view.state.facet(EditorView.atomicRanges)) {
|
||||
fn(view).between(0, view.state.doc.length, (from, to) => {
|
||||
out.push([from, to])
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
}
|
||||
|
||||
function lineTexts(view: EditorView): string[] {
|
||||
return [...view.dom.querySelectorAll('.cm-line')].map((el) => el.textContent ?? '')
|
||||
}
|
||||
|
||||
function replaceDoc(view: EditorView, from: number, to: number, insert: string): void {
|
||||
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const v of views.splice(0)) v.destroy()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('文首边界', () => {
|
||||
it('以标题开头:标记隐藏、整行 h1、标记区间原子化', () => {
|
||||
const doc = '# 标题\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
expect(view.dom.querySelectorAll('.cm-md-h1')).toHaveLength(1)
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
})
|
||||
|
||||
it('光标停在文档起点回退:不抛错、内容不变、光标不越界', () => {
|
||||
const doc = '# 标题\n\n正文'
|
||||
const view = mount(doc, 0)
|
||||
deleteCharBackward(view)
|
||||
expect(view.state.doc.toString()).toBe(doc)
|
||||
expect(view.state.selection.main.head).toBe(0)
|
||||
})
|
||||
|
||||
it('以任务项开头:复选框渲染在文首、标记让位、列表标记原子化', () => {
|
||||
const doc = '- [ ] 第一项\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelectorAll('.cm-md-task')).toHaveLength(1)
|
||||
expect(lineTexts(view)[0]).not.toContain('-')
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2]) // `- `
|
||||
})
|
||||
|
||||
it('以围栏开头:语言标签在文首渲染,块内不解析行内语法', () => {
|
||||
const doc = '```js\n**原样**\n```\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelector('.cm-md-fence-lang')?.textContent).toBe('js')
|
||||
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('以水平线开头:hr widget 渲染在文首', () => {
|
||||
const doc = '---\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
expect(lineTexts(view)[0]).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('文末边界', () => {
|
||||
it('以水平线结尾:hr widget 渲染在最后一行', () => {
|
||||
const doc = '正文\n\n---'
|
||||
const view = mount(doc, 0)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
const last = lineTexts(view).at(-1)
|
||||
expect(last).toBe('')
|
||||
expect(atomicRanges(view)).toContainEqual([doc.length - 3, doc.length])
|
||||
})
|
||||
|
||||
it('文档只有一条水平线:不抛错且渲染 hr', () => {
|
||||
const view = mount('---', 0)
|
||||
expect(view.state.doc.length).toBe(3)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('以图片结尾:img widget 在文末,src 与 alt 正确', () => {
|
||||
const doc = '正文\n\n'
|
||||
const view = mount(doc, 0)
|
||||
const img = view.dom.querySelector('.cm-md-image') as HTMLImageElement | null
|
||||
expect(img).not.toBeNull()
|
||||
expect(img?.getAttribute('src')).toBe('/api/images/9')
|
||||
expect(img?.getAttribute('alt')).toBe('配图')
|
||||
})
|
||||
|
||||
it('以围栏结尾:闭围栏隐藏,末行仍带 fenced 类', () => {
|
||||
const doc = '正文\n\n```js\nconst a = 1\n```'
|
||||
const view = mount(doc, 0)
|
||||
expect(lineTexts(view).at(-1)).toBe('')
|
||||
expect(lineTexts(view).at(-2)).toBe('const a = 1')
|
||||
expect(view.dom.querySelectorAll('.cm-md-fenced')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('以任务项结尾:复选框渲染在最后一行', () => {
|
||||
const doc = '正文\n\n- [x] 末项'
|
||||
const view = mount(doc, 0)
|
||||
const tasks = view.dom.querySelectorAll('.cm-md-task')
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect((tasks[0] as HTMLInputElement).checked).toBe(true)
|
||||
expect(lineTexts(view).at(-1)).not.toContain('[x]')
|
||||
})
|
||||
|
||||
it('文末追加内容后装饰正确重建(不给文末留残影)', () => {
|
||||
const doc = '正文\n\n---'
|
||||
const view = mount(doc, 0)
|
||||
view.dispatch({ changes: { from: doc.length, insert: '\n\n## 新小节\n\n新段落' } })
|
||||
expect(lineTexts(view).some((t) => t === '新小节')).toBe(true)
|
||||
expect(view.dom.querySelectorAll('.cm-md-h2')).toHaveLength(1)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('文末整体清空(全选删除)不抛错', () => {
|
||||
const doc = '# 标题\n\n- A\n\n\n\n---\n\n```js\nx\n```\n'
|
||||
const view = mount(doc, 0)
|
||||
replaceDoc(view, 0, doc.length, '')
|
||||
expect(view.state.doc.toString()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('跨原子区间选区', () => {
|
||||
const doc = '# 标题\n\n- 甲\n\n段落 **粗体** 结尾\n'
|
||||
|
||||
it('全选替换:跨多个隐藏标记的选区可整体替换', () => {
|
||||
const view = mount(doc, doc.length)
|
||||
replaceDoc(view, 0, doc.length, 'X')
|
||||
expect(view.state.doc.toString()).toBe('X')
|
||||
})
|
||||
|
||||
it('选区横跨隐藏标记:替换结果不残留标记碎片', () => {
|
||||
const view = mount(doc, doc.length)
|
||||
const boldStart = doc.indexOf('**')
|
||||
replaceDoc(view, 0, boldStart + 2, '替换')
|
||||
expect(view.state.doc.toString()).toBe('替换粗体** 结尾\n')
|
||||
})
|
||||
|
||||
it('只选中可见内容时替换,标记完整保留', () => {
|
||||
const doc2 = '段落 **粗体** 结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
const from = doc2.indexOf('粗体')
|
||||
replaceDoc(view, from, from + 2, 'X')
|
||||
expect(view.state.doc.toString()).toBe('段落 **X** 结尾')
|
||||
})
|
||||
|
||||
it('选中「标记 + 内容 + 标记」整体删除后,只剩两侧文字', () => {
|
||||
const doc2 = '段落 **粗体** 结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
replaceDoc(view, 3, 9, '')
|
||||
expect(view.state.doc.toString()).toBe('段落 结尾')
|
||||
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('删除跨行的选区(含列表行与引用行)后装饰重建正确', () => {
|
||||
const doc2 = '前言\n\n- 甲\n- 乙\n\n> 引用\n\n结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
replaceDoc(view, 2, doc2.indexOf('结尾'), '')
|
||||
const left = view.state.doc.toString()
|
||||
expect(left).toContain('前言')
|
||||
expect(left).toContain('结尾')
|
||||
expect(left).not.toContain('甲')
|
||||
expect(left).not.toContain('引用')
|
||||
expect(view.dom.querySelectorAll('.cm-md-bullet')).toHaveLength(0)
|
||||
expect(view.dom.querySelectorAll('.cm-md-quote-bar')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('装饰层不变量', () => {
|
||||
const docs = [
|
||||
'# 标题\n\n正文 **粗** 与 *斜* 与 `码` 与 [链](https://a.example)\n',
|
||||
'- [ ] 待办\n- [x] 完成\n - 嵌套\n1. 有序\n',
|
||||
'> 一\n> > 二\n\n---\n\n```js\ncode\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n',
|
||||
'\n\n结尾\n',
|
||||
]
|
||||
|
||||
it('原子区间不跨行(CM6 硬性约束)', () => {
|
||||
for (const doc of docs) {
|
||||
for (const anchor of [0, doc.length]) {
|
||||
const view = mount(doc, anchor)
|
||||
for (const [from, to] of atomicRanges(view)) {
|
||||
expect(view.state.doc.lineAt(from).number).toBe(view.state.doc.lineAt(to).number)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('原子区间有序且互不重叠', () => {
|
||||
for (const doc of docs) {
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
expect(ranges[i][0]).toBeGreaterThanOrEqual(ranges[i - 1][1])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('隐藏标记一定落在原子区间内(退格可整段删净)', () => {
|
||||
const doc = '# 标题\n\n- 甲\n> 乙\n\n**粗**\n'
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
const tree = syntaxTree(view.state)
|
||||
for (const spec of collectSpecs(tree, view.state.doc, 0, doc.length)) {
|
||||
if (spec.kind !== 'hide') continue
|
||||
expect(ranges).toContainEqual([spec.from, spec.to])
|
||||
}
|
||||
})
|
||||
|
||||
it('强调 / 行内代码 / 链接的内容区间绝不被原子化(§4.2)', () => {
|
||||
const doc = '文字 **粗体** 与 `代码` 与 [链接](https://a.example) 结束\n'
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
const tree = syntaxTree(view.state)
|
||||
const contentSpecs = collectSpecs(tree, view.state.doc, 0, doc.length).filter(
|
||||
(s) => s.kind === 'mark' && ['cm-md-strong', 'cm-md-code', 'cm-md-link'].includes(s.cls),
|
||||
)
|
||||
expect(contentSpecs).toHaveLength(3)
|
||||
for (const spec of contentSpecs) {
|
||||
for (const [from, to] of ranges) {
|
||||
expect(spec.from >= to || spec.to <= from).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reveal 契约:光标进入某行后该行原子区间消失,离开后恢复', () => {
|
||||
const doc = '# 标题\n\n正文 **粗** 结束\n'
|
||||
const view = mount(doc, doc.length) // 光标在末行
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
|
||||
view.dispatch({ selection: { anchor: 1 } }) // 光标进入标题行
|
||||
expect(atomicRanges(view)).not.toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('# 标题')
|
||||
|
||||
view.dispatch({ selection: { anchor: doc.length } }) // 离开
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
})
|
||||
|
||||
it('失焦(未聚焦)时不展开任何行,整篇按渲染态呈现', () => {
|
||||
const doc = '# 标题\n\n正文\n'
|
||||
const view = mount(doc, 5, false) // 不调用 focus()
|
||||
expect(view.hasFocus).toBe(false)
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('长文档:惰性解析与装饰补齐', () => {
|
||||
function buildDoc(lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
|
||||
it('如实记录:syntaxTree 对长文档只返回已解析的前段(lezer 惰性增量)', () => {
|
||||
const partial = syntaxTree(state).length
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[parse] 3000 行文档(${doc.length} 字符):syntaxTree 首次仅覆盖 ${partial} 字符`)
|
||||
expect(partial).toBeLessThan(doc.length)
|
||||
expect(partial).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('visibleSyntaxTree 在给定预算内把可见区间补齐,装饰不再缺失', () => {
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||
const cold = syntaxTree(state).length
|
||||
const tree = visibleSyntaxTree(state, [win], 2000)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[parse] 补解析窗口 [${win.from}, ${win.to}]:树覆盖 ${cold} → ${tree.length}`)
|
||||
expect(tree.length).toBeGreaterThanOrEqual(win.to)
|
||||
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||
expect(decorations.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('预算受限时越界区间保持源码态,且不会抛错(等解析推进后自动补齐)', () => {
|
||||
const win = { from: state.doc.line(2900).from, to: state.doc.line(2950).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 20)
|
||||
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||
if (tree.length < win.from) expect(decorations.size).toBe(0)
|
||||
else expect(decorations.size).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('装饰不会越过已解析边界(避免对半截节点做出错误渲染)', () => {
|
||||
const partialLen = syntaxTree(state).length
|
||||
const { decorations } = buildDecorations(state, [{ from: 0, to: doc.length }], false, syntaxTree(state))
|
||||
let maxTo = 0
|
||||
decorations.between(0, doc.length, (_f, to) => {
|
||||
if (to > maxTo) maxTo = to
|
||||
})
|
||||
expect(maxTo).toBeLessThanOrEqual(partialLen)
|
||||
})
|
||||
|
||||
it('光标行远在可见区间之外时仍被装饰(CM 会单独渲染选区所在块)', () => {
|
||||
// 选区停在文档开头(第 1 行是 `## 小节 0`),可见区间取远端窗口
|
||||
const win = { from: state.doc.line(2500).from, to: state.doc.line(2510).to }
|
||||
const { decorations } = buildDecorations(state, [win], false)
|
||||
let startLineClass: string | null = null
|
||||
decorations.between(0, state.doc.line(1).to, (_f, _t, value) => {
|
||||
if (typeof value.spec.class === 'string') startLineClass = value.spec.class
|
||||
})
|
||||
expect(startLineClass).toBe('cm-md-h2')
|
||||
|
||||
// 远端窗口同样被装饰:补解析 + 采集覆盖了两段
|
||||
let farCount = 0
|
||||
decorations.between(win.from, win.to, () => {
|
||||
farCount++
|
||||
})
|
||||
expect(farCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('性能基准(§7.4:3000 行文档单帧 < 16ms)', () => {
|
||||
function buildDoc(lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
const median = (xs: number[]): number => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)]
|
||||
|
||||
it('视口窗口(约 100 行)装饰计算中位数 < 16ms', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 5000) // 预热到窗口可用
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const t0 = performance.now()
|
||||
const specs = collectSpecs(tree, state.doc, win.from, win.to)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(specs.length).toBeGreaterThan(50)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 3000 行 / 视口 100 行:collectSpecs 中位数 ${med.toFixed(3)}ms,p95 ${median(samples.slice().sort((a, b) => a - b).slice(-2)).toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(16)
|
||||
})
|
||||
|
||||
it('整篇作为单一可见区间(最坏情况)仍远低于一帧量级', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||
expect(tree.length).toBe(doc.length)
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const t0 = performance.now()
|
||||
const specs = collectSpecs(tree, state.doc, 0, doc.length)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(specs.length).toBeGreaterThan(3000)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 3000 行 / 全篇单区间:collectSpecs 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('冷启动成本实测:首次强制解析整篇 3000 行', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const t0 = performance.now()
|
||||
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||
const ms = performance.now() - t0
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[perf] 3000 行(${doc.length} 字符)全量解析耗时 ${ms.toFixed(1)}ms(树覆盖 ${tree.length} 字符)`)
|
||||
expect(tree.length).toBe(doc.length)
|
||||
})
|
||||
|
||||
it('最坏选区:Cmd+A 整篇选区 + 视口窗口,装饰构建仍在一帧预算内', () => {
|
||||
const doc = buildDoc(3000)
|
||||
// 整篇选区 + 聚焦(触发 reveal,展开区间多达 3000 个)
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor: 0, head: doc.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1560).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 10000)
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const t0 = performance.now()
|
||||
const { decorations } = buildDecorations(state, [win], true, tree)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(decorations.size).toBeGreaterThan(0)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 最坏选区(整篇选中):buildDecorations 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(16)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
// 即时渲染装饰层单测(方案 §8):
|
||||
// 1) syntax.ts 区间识别:标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 /
|
||||
// 任务 / 围栏 / 水平线 / 表格,覆盖嵌套、代码块内不解析、中文标点;
|
||||
// 2) reveal.ts:行粒度展开只撤销「标记替代物」,样式类保留;
|
||||
// 3) decorations.ts:白名单外的图片 src 退回源码文本;widget DOM 无注入面。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { collectSpecs, type Spec } from '../src/editor/syntax'
|
||||
import { activeLineRanges, applyReveal, intersectsAny, selectionEndpointLines } from '../src/editor/reveal'
|
||||
import { buildDecorations, IMAGE_SRC_ALLOW } from '../src/editor/decorations'
|
||||
import { FenceLangWidget, ImageWidget, TaskWidget } from '../src/editor/widgets'
|
||||
|
||||
function makeState(src: string, cursor = 0) {
|
||||
return EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: cursor },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集全文档 specs,按位置排序,便于断言。 */
|
||||
function collect(src: string): Spec[] {
|
||||
const state = makeState(src)
|
||||
const specs = collectSpecs(syntaxTree(state), state.doc, 0, src.length)
|
||||
return specs.slice().sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
}
|
||||
|
||||
/** 断言用的简化视图:`kind:原文`。 */
|
||||
function digest(src: string, specs = collect(src)): string[] {
|
||||
return specs.map((s) => {
|
||||
if (s.kind === 'line') return `line(#${s.line}:${s.cls})`
|
||||
const text = src.slice(s.from, s.to)
|
||||
const extra =
|
||||
s.kind === 'bullet' || s.kind === 'quote' ? ` d${s.depth}` : s.kind === 'mark' ? ` ${s.cls}` : ''
|
||||
return `${s.kind}(${JSON.stringify(text)}${extra})`
|
||||
})
|
||||
}
|
||||
|
||||
describe('syntax.ts 区间识别', () => {
|
||||
it('标题:隐藏 `# `,整行上 h1 类,行内语法继续解析', () => {
|
||||
const src = '# 标题 **粗**'
|
||||
const d = digest(src)
|
||||
expect(d).toContain('line(#1:cm-md-h1)')
|
||||
expect(d).toContain('hide("# ")')
|
||||
expect(d).toContain('hide("**")')
|
||||
expect(d).toContain('mark("粗" cm-md-strong)')
|
||||
})
|
||||
|
||||
it('强调:隐藏定界符,内容上样式(含嵌套)', () => {
|
||||
const d = digest('a **粗** b *斜* c ~~删~~')
|
||||
expect(d).toEqual([
|
||||
'hide("**")',
|
||||
'mark("粗" cm-md-strong)',
|
||||
'hide("**")',
|
||||
'hide("*")',
|
||||
'mark("斜" cm-md-em)',
|
||||
'hide("*")',
|
||||
'hide("~~")',
|
||||
'mark("删" cm-md-del)',
|
||||
'hide("~~")',
|
||||
])
|
||||
})
|
||||
|
||||
it('行内代码:隐藏反引号,内容不二次解析(`*x*` 保持字面量)', () => {
|
||||
const d = digest('`*x*`')
|
||||
expect(d).toEqual(['hide("`")', 'mark("*x*" cm-md-code)', 'hide("`")'])
|
||||
})
|
||||
|
||||
it('链接:隐藏 `[` 与 `](url)`,文本上链接类;不产出 <a>', () => {
|
||||
const src = '[标题](https://a.example/x)'
|
||||
const d = digest(src)
|
||||
expect(d).toEqual(['hide("[")', 'mark("标题" cm-md-link)', 'hide("](https://a.example/x)")'])
|
||||
})
|
||||
|
||||
it('图片:整段替换为 image spec,带 alt 与 src', () => {
|
||||
const specs = collect('')
|
||||
const img = specs.find((s) => s.kind === 'image')
|
||||
expect(img).toMatchObject({ alt: '配图', src: '/api/images/7' })
|
||||
})
|
||||
|
||||
it('引用:每个 QuoteMark 一个竖线 widget,嵌套深度递增,同行去重加 cm-md-quote', () => {
|
||||
const src = '> 一\n> > 二'
|
||||
const d = digest(src)
|
||||
expect(d.filter((x) => x.startsWith('quote('))).toEqual(['quote(">" d1)', 'quote(">" d1)', 'quote(">" d2)'])
|
||||
// 第 2 行有两个 QuoteMark,原样各出一条行类;构建阶段按键去重后每行只剩一条
|
||||
expect(d.filter((x) => x === 'line(#1:cm-md-quote)')).toHaveLength(1)
|
||||
expect(d.filter((x) => x === 'line(#2:cm-md-quote)')).toHaveLength(2)
|
||||
|
||||
const state = makeState(src)
|
||||
const { decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||
const lineClasses: unknown[] = []
|
||||
decorations.between(0, src.length, (_f, _t, value) => {
|
||||
if (value.spec.class) lineClasses.push(value.spec.class)
|
||||
})
|
||||
expect(lineClasses.filter((c) => c === 'cm-md-quote')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('无序列表:圆点 widget 覆盖「标记 + 空格」,嵌套深度递增', () => {
|
||||
const d = digest('- A\n - B')
|
||||
expect(d.filter((x) => x.startsWith('bullet('))).toEqual(['bullet("- " d1)', 'bullet("- " d2)'])
|
||||
})
|
||||
|
||||
it('有序列表:保留数字,仅上样式,不替换不原子(决策 E3)', () => {
|
||||
const d = digest('1. 一\n2. 二')
|
||||
expect(d).toEqual(['mark("1." cm-md-ol-mark)', 'mark("2." cm-md-ol-mark)'])
|
||||
})
|
||||
|
||||
it('任务项:标记让位给复选框,不画圆点;checked 正确', () => {
|
||||
const d = digest('- [ ] 待办\n- [x] 完成')
|
||||
expect(d.some((x) => x.startsWith('bullet('))).toBe(false)
|
||||
expect(d).toContain('hide("- ")')
|
||||
expect(d.filter((x) => x.startsWith('task('))).toEqual(['task("[ ]")', 'task("[x]")'])
|
||||
const specs = collect('- [x] 完成')
|
||||
expect(specs.find((s) => s.kind === 'task')).toMatchObject({ checked: true })
|
||||
})
|
||||
|
||||
it('围栏:语言标签 + 隐藏闭围栏 + 每行 fenced 类;块内不解析行内语法', () => {
|
||||
const src = '```js\n**not bold**\n```'
|
||||
const d = digest(src)
|
||||
expect(d).toContain('fenceLang("```js")')
|
||||
expect(collect(src).find((s) => s.kind === 'fenceLang')).toMatchObject({ lang: 'js' })
|
||||
expect(d).toContain('line(#1:cm-md-fenced cm-md-fenced-first)')
|
||||
expect(d).toContain('line(#3:cm-md-fenced cm-md-fenced-last)')
|
||||
expect(d.some((x) => x.includes('cm-md-strong'))).toBe(false)
|
||||
})
|
||||
|
||||
it('无语言围栏:起始围栏直接隐藏', () => {
|
||||
const d = digest('```\nx\n```')
|
||||
expect(d.some((x) => x.startsWith('fenceLang('))).toBe(false)
|
||||
expect(d).toContain('hide("```")')
|
||||
})
|
||||
|
||||
it('水平线:整行替换', () => {
|
||||
const d = digest('---')
|
||||
expect(d).toEqual(['hr("---")'])
|
||||
})
|
||||
|
||||
it('表格:每行 cm-md-table,定界符淡化(分隔行两级)', () => {
|
||||
const src = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
||||
const d = digest(src)
|
||||
expect(d.filter((x) => x.startsWith('line('))).toEqual([
|
||||
'line(#1:cm-md-table)',
|
||||
'line(#2:cm-md-table)',
|
||||
'line(#3:cm-md-table)',
|
||||
])
|
||||
const specs = collect(src)
|
||||
const delims = specs.filter((s): s is Extract<Spec, { kind: 'tableMark' }> => s.kind === 'tableMark')
|
||||
expect(delims.some((s) => s.delimiter)).toBe(true)
|
||||
expect(delims.filter((s) => s.delimiter)).toHaveLength(1) // 只有分隔行是两级淡化
|
||||
expect(delims).toHaveLength(7) // 4 行共 7 个竖线标记
|
||||
})
|
||||
|
||||
it('中文标点与紧邻标记不误判', () => {
|
||||
const d = digest('他说:「**重点**」。')
|
||||
expect(d).toEqual(['hide("**")', 'mark("重点" cm-md-strong)', 'hide("**")'])
|
||||
})
|
||||
|
||||
it('转义星号不触发强调', () => {
|
||||
const d = digest('\\*不是斜体\\*')
|
||||
expect(d).toEqual([])
|
||||
})
|
||||
|
||||
it('可见区间裁剪:只采集区间内节点', () => {
|
||||
const src = '# 一\n\n# 二'
|
||||
const state = makeState(src)
|
||||
const line3 = state.doc.line(3)
|
||||
const specs = collectSpecs(syntaxTree(state), state.doc, line3.from, line3.to)
|
||||
expect(specs.filter((s) => s.kind === 'line')).toHaveLength(1)
|
||||
expect(specs.find((s) => s.kind === 'line')).toMatchObject({ line: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reveal.ts 光标处展开(行粒度)', () => {
|
||||
it('intersectsAny 只认真正的相交', () => {
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 5, 6)).toBe(true)
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 6, 8)).toBe(false)
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 3, 3)).toBe(false)
|
||||
})
|
||||
|
||||
it('光标行上的标记替代物被撤销,样式类保留', () => {
|
||||
const src = 'a **粗** b'
|
||||
const state = makeState(src, 5) // 光标落在粗体所在行
|
||||
const specs = collect(src)
|
||||
const shown = applyReveal(specs, activeLineRanges(state))
|
||||
expect(shown.some((s) => s.kind === 'hide')).toBe(false)
|
||||
expect(shown.some((s) => s.kind === 'mark' && s.cls === 'cm-md-strong')).toBe(true)
|
||||
})
|
||||
|
||||
it('光标在别的行时标记保持隐藏', () => {
|
||||
const src = '第一行\n第二行 **粗**'
|
||||
const state = makeState(src, 0)
|
||||
const specs = collect(src)
|
||||
const shown = applyReveal(specs, activeLineRanges(state))
|
||||
expect(shown.filter((s) => s.kind === 'hide')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('选区横跨多行时,涉及的行全部展开', () => {
|
||||
const src = '> 一\n> > 二'
|
||||
const state = EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: 0, head: src.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
const shown = applyReveal(collect(src), activeLineRanges(state))
|
||||
expect(shown.filter((s) => s.kind === 'quote')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('二分优化与线性扫描结果完全一致(含空/重叠/乱序/相邻区间)', () => {
|
||||
const src = '# 标题 **粗** 与 `码` 与 [链](https://a.example)\n\n- 甲\n> 乙\n\n---\n'
|
||||
const specs = collect(src)
|
||||
const cases: { from: number; to: number }[][] = [
|
||||
[],
|
||||
[{ from: 0, to: 2 }],
|
||||
[{ from: 0, to: 2 }, { from: 2, to: 4 }],
|
||||
[{ from: 5, to: 9 }, { from: 0, to: 3 }], // 乱序
|
||||
[{ from: 0, to: 10 }, { from: 4, to: 6 }], // 重叠
|
||||
[{ from: 0, to: src.length }], // 整篇
|
||||
[{ from: 3, to: 3 }], // 零长
|
||||
]
|
||||
for (const ranges of cases) {
|
||||
const linear = specs.filter((s) => !(s.reveal && intersectsAny(ranges, s.from, s.to)))
|
||||
expect(applyReveal(specs, ranges)).toEqual(linear)
|
||||
}
|
||||
})
|
||||
|
||||
it('selectionEndpointLines 只取选区端点行(Cmd+A 不退化为全文档装饰)', () => {
|
||||
const src = '第 1 行\n第 2 行\n第 3 行\n第 4 行'
|
||||
const state = EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: 0, head: src.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
expect(activeLineRanges(state)).toHaveLength(4)
|
||||
const endpoints = selectionEndpointLines(state)
|
||||
expect(endpoints).toHaveLength(2)
|
||||
expect(endpoints[0].from).toBe(0)
|
||||
expect(endpoints[1].to).toBe(state.doc.line(4).to)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations.ts 构建与安全', () => {
|
||||
const src = '# 标题\n\n- A\n\n\n'
|
||||
|
||||
it('生成装饰集且原子区间与隐藏区间数量一致', () => {
|
||||
const state = makeState(src)
|
||||
const { decorations, atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||
expect(decorations.size).toBeGreaterThan(0)
|
||||
// hide(1: `# `) + hide(1: `- `) = 2 个原子区间
|
||||
expect(atomic.size).toBe(2)
|
||||
})
|
||||
|
||||
it('光标行上的标记不进入原子区间', () => {
|
||||
const state = makeState(src, 0) // 光标在标题行
|
||||
const { atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||
expect(atomic.size).toBe(2) // 标题标记已展开,只剩列表标记 + 图片替换
|
||||
})
|
||||
|
||||
it('未聚焦(reveal=false)时整篇按渲染态呈现,光标行的标记也隐藏(决策 E11)', () => {
|
||||
const state = makeState(src, 0)
|
||||
const { atomic, decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||
expect(atomic.size).toBe(3) // 标题 + 列表 + 图片全部原子化
|
||||
const hidden: string[] = []
|
||||
decorations.between(0, src.length, (from, to) => {
|
||||
if (to > from) hidden.push(src.slice(from, to))
|
||||
})
|
||||
expect(hidden).toContain('# ')
|
||||
})
|
||||
|
||||
it('图片 src 白名单:站内接口放行,javascript:/外链降级为源码文本', () => {
|
||||
expect(IMAGE_SRC_ALLOW.test('/api/images/12')).toBe(true)
|
||||
expect(IMAGE_SRC_ALLOW.test('data:image/png;base64,AAAA')).toBe(true)
|
||||
expect(IMAGE_SRC_ALLOW.test('javascript:alert(1)')).toBe(false)
|
||||
expect(IMAGE_SRC_ALLOW.test('https://evil.example/x.png')).toBe(false)
|
||||
|
||||
const bad = ')'
|
||||
const badState = makeState(bad)
|
||||
const badSet = buildDecorations(badState, [{ from: 0, to: bad.length }], true).decorations
|
||||
expect(badSet.size).toBe(0) // 不产出 <img>,源码原样可见
|
||||
|
||||
const goodState = makeState(src)
|
||||
const goodSet = buildDecorations(goodState, [{ from: 0, to: src.length }], true).decorations
|
||||
const widgets: unknown[] = []
|
||||
goodSet.between(0, src.length, (_from, _to, value) => {
|
||||
widgets.push(value.spec.widget)
|
||||
})
|
||||
expect(widgets.some((w) => w instanceof ImageWidget)).toBe(true)
|
||||
})
|
||||
|
||||
it('widget DOM 走 textContent,语言名里的 HTML 不被解析', () => {
|
||||
const dom = new FenceLangWidget('<img src=x onerror=alert(1)>').toDOM()
|
||||
expect(dom.children).toHaveLength(0)
|
||||
expect(dom.innerHTML).not.toContain('<img')
|
||||
expect(dom.textContent).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('图片 widget 不写入 innerHTML,只设置属性', () => {
|
||||
const dom = new ImageWidget('/api/images/1', '"><script>alert(1)</script>').toDOM()
|
||||
expect(dom.querySelector('script')).toBeNull()
|
||||
expect(dom.getAttribute('src')).toBe('/api/images/1')
|
||||
expect(dom.getAttribute('alt')).toBe('"><script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('任务 widget 挂载时不产生额外 DOM 面', () => {
|
||||
const dom = new TaskWidget(0, 3, false).toDOM({ dispatch() {} } as never) as HTMLInputElement
|
||||
expect(dom.tagName).toBe('INPUT')
|
||||
expect(dom.getAttribute('type')).toBe('checkbox')
|
||||
expect(dom.checked).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user