diff --git a/.gitignore b/.gitignore index 8123427..aa3d224 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ android/.kotlin/ # 本地上传文件(附件落盘目录,绝不入库) backend/var/ + +# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB) +# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库 +web/public/vditor/ diff --git a/2.Design/DEVELOPMENT.md b/2.Design/DEVELOPMENT.md index 22a8c75..9611f88 100644 --- a/2.Design/DEVELOPMENT.md +++ b/2.Design/DEVELOPMENT.md @@ -182,6 +182,8 @@ release 仍走白名单,线上域名必须 HTTPS。 | 迁移报 `cannot insert multiple commands into a prepared statement` | asyncpg 走预备语句,一次只能一条 SQL | 多语句 DDL 拆成每语句一次 `op.execute()` | | 源文件里的中文变成 `宸ュ崟鍩?` | 用 PowerShell `Get-Content`/`Set-Content` 批量改文件:读时按 GBK 解码,写回 UTF-8,不可逆 | **含中文的源文件只能用编辑器/`edit` 工具改**;真要写脚本,必须显式 `[IO.File]::ReadAllText($p, [Text.Encoding]::UTF8)` + `WriteAllText(..., UTF8Encoding($false))` | | `npm` 报 `无法加载文件 npm.ps1,因为在此系统上禁止运行脚本` | PowerShell 执行策略 | 用 `npm.cmd`,或直接 `node node_modules/vite/bin/vite.js build` | +| 富文本编辑器一直转圈、没有工具栏 | `web/public/vditor` 缺失(它被 gitignore,由脚本生成) | `cd web && npm run vendor`,或走一遍 `predev`/`prebuild` | +| 编辑器能开但排版是源码、不是所见即所得 | `lute.min.js` 没自托管成功,Vditor 退回纯文本模式 | 打开控制台看 `/vditor/dist/js/lute/lute.min.js` 是不是 404 | ### 团队共享开发库(fnsvr-01:8432) diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 20f4ffb..45b10f8 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -354,11 +354,14 @@ ## 5. 富文本与附件(M2.5) -### 5.1 决策:正文存 Markdown,不存 HTML +### 5.1 决策:正文存 Markdown,编辑用成熟组件 Vditor - [x] 任务备注、工单说明与验收结论、知识正文、评论,统一 `*_md` 字段存 Markdown -- [x] 渲染器自研零依赖(`web/src/lib/markdown.ts`),**先转义再上标签**,链接协议白名单 `http(s)/mailto//` -- [x] 不做 contenteditable 富文本:它产出 HTML,导出/检索/喂 LLM 都要再清洗,且 `dangerouslySetInnerHTML` 面积更大 -- [ ] 表格与 HTML 片段:暂不支持,按原样当文本(等真有需求再加) +- [x] 渲染用 `markdown-it` + `dompurify`(先渲染再消毒),链接协议白名单 `http(s)/mailto//` +- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown +- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17 +- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载 +- [ ] 表格与 HTML 片段:暂不支持(Vditor 支持表格,只是我们的只读渲染器还没开) + ### 5.2 附件 - [x] 后端:`attachment` 表 + RLS + 迁移 0005;`POST/GET/DELETE` 与 `download`/`raw` 两个读取口 @@ -370,13 +373,14 @@ - [ ] 缩略图、图片压缩、病毒扫描:未做 ### 5.3 编辑器 -- [x] `RichTextEditor`:工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片)+ `Ctrl+B`/`Ctrl+I` + 预览切换 -- [x] 粘贴与拖拽上传图片,自动在光标处插入 `![文件名](attachmentUrl(...))` +- [x] `RichTextEditor` = Vditor 封装:`mode: "ir"`、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉 `cache`(草稿由我们自己的保存按钮负责) +- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑 +- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`` 带不了 Authorization 头,所以 `raw?token=` 是必要折中 - [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`) - [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议) ### 5.4 验证 - [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权) -- [x] `web/e2e/richtext.mjs` → 9 项全绿(渲染、贴图上传后 `/raw` 返回 200、附件落库后重开仍在、控制台无报错) +- [x] `web/e2e/richtext.mjs` → **13 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含 token、贴图后 `/raw` 返回 200、附件重开仍在、控制台无报错) - [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7) -- [x] `tsc -b` 0 错;`vite build` 255.07 kB │ gzip 80.85 kB \ No newline at end of file +- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 384.73 kB │ gzip 135.82 kB(按需加载,只进详情抽屉) diff --git a/2.Design/screens/web-editor-preview.png b/2.Design/screens/web-editor-preview.png deleted file mode 100644 index 4fd4488..0000000 Binary files a/2.Design/screens/web-editor-preview.png and /dev/null differ diff --git a/2.Design/screens/web-editor-write.png b/2.Design/screens/web-editor-write.png index ea9649e..ec28833 100644 Binary files a/2.Design/screens/web-editor-write.png and b/2.Design/screens/web-editor-write.png differ diff --git a/README.md b/README.md index 14de2e5..ebb8982 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ | 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 | | 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 | | 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 | -| 富文本:备注 / 工单说明 / 知识正文支持 Markdown 格式、图片、附件 | ✅ 后端 + Web 已通(附件接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节,e2e 9 项全绿);安卓暂只读 | +| 富文本:备注 / 工单说明 / 知识正文支持图文混排(Vditor 编辑器)与附件 | ✅ 后端 + Web 已通(附件接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节,e2e 13 项全绿);安卓暂只读 | | 后端测试 | ✅ 70 passed,ruff 干净 | | 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 | | LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 | diff --git a/web/e2e/richtext.mjs b/web/e2e/richtext.mjs index 8686349..c91b067 100644 --- a/web/e2e/richtext.mjs +++ b/web/e2e/richtext.mjs @@ -1,8 +1,11 @@ /** - * 富文本与附件 e2e:备注能写 Markdown、贴图能上传并渲染、附件能传能列。 + * 富文本与附件 e2e:备注用 Vditor 写 Markdown、贴图能上传并显示、附件能传能列。 * - * 这里刻意走真浏览器(不是只测接口)——因为要验的正是"编辑器把内容写回后, - * 渲染出来是不是那个样子",以及 带 token 的那条路能不能真的取到图。 + * 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事: + * 1. 输 `**粗体**` 之后编辑器里真的变成了 (IR 模式的输入规则生效); + * 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了令牌); + * 3. 存进库的正文是**干净**的(不含 ?token=),否则几天后满篇红叉; + * 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。 */ import { chromium } from "playwright-core"; @@ -46,6 +49,8 @@ const browser = await chromium.launch({ }); const problems = []; +/** 记录所有 /raw 请求的状态码,验证图片确实被取到 */ +const rawHits = []; const context = await browser.newContext({ viewport: { width: 1360, height: 940 } }); const page = await context.newPage(); page.on("console", (m) => { @@ -53,14 +58,7 @@ page.on("console", (m) => { }); page.on("response", (r) => { if (r.status() >= 400) problems.push(`${r.status()} ${r.url()}`); -}); - -/** 记录所有 /raw 请求的状态码,验证图片确实被取到 */ -const rawHits = []; -page.on("response", (r) => { - if (r.url().includes("/attachments/") && r.url().includes("/raw")) { - rawHits.push(r.status()); - } + if (r.url().includes("/attachments/") && r.url().includes("/raw")) rawHits.push(r.status()); }); try { @@ -93,57 +91,74 @@ try { const drawer = page.getByRole("dialog"); await drawer.waitFor(); - // —— 1. Markdown 写作与预览 —— + // —— 1. 编辑器确实是成熟组件(Vditor),而不是自研 textarea —— + await page.waitForSelector(".vditor-ir", { timeout: 20000 }); + await page.waitForFunction(() => Boolean(window.Lute), null, { timeout: 20000 }); + check("编辑器加载为 Vditor(lute 已就绪,非 CDN 依赖)", true); + const editor = drawer.getByLabel("备注"); await editor.click(); - await editor.fill("**必须** 一起寄出,参考 `财务口径 v3`。\n\n- 纸质发票\n- 快递单"); + // 逐字输入,让 IR 模式的输入规则生效(`**` 收尾会转成粗体,先打字再回车) + await page.keyboard.type("**必须** 一起寄出,参考 ", { delay: 25 }); + await page.keyboard.type("`财务口径 v3`", { delay: 25 }); + await page.keyboard.type("。", { delay: 25 }); + await page.keyboard.press("Enter"); + await page.keyboard.type("- 纸质发票", { delay: 40 }); + await page.keyboard.press("Enter"); + // 列表里回车会自然续上新的一项,不需要再打一遍 "- "(打了会变成嵌套列表) + await page.keyboard.type("快递单", { delay: 40 }); + await page.waitForTimeout(500); + + const boldCount = await drawer.locator(".vditor-ir strong").count(); + const codeCount = await drawer.locator(".vditor-ir code").count(); + const listCount = await drawer.locator(".vditor-ir li").count(); + check("输入 **粗体** 就地渲染成 strong", boldCount > 0, `strong=${boldCount}`); + check("输入 `行内代码` 就地渲染成 code", codeCount > 0, `code=${codeCount}`); + check("输入 - 列表就地渲染成 li(两条)", listCount === 2, `li=${listCount}`); + await drawer.getByRole("button", { name: "保存备注" }).click(); - await drawer.getByRole("button", { name: "预览" }).click(); + await page.waitForTimeout(600); - check("预览渲染粗体", (await drawer.locator(".prose-dadao strong").count()) > 0); - check("预览渲染行内代码", (await drawer.locator(".prose-dadao code").count()) > 0); - check("预览渲染列表", (await drawer.locator(".prose-dadao li").count()) === 2); + // —— 2. 库里存的是干净正文:不能把令牌写进去 —— + const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token); + const body = String(saved.body_md ?? ""); + check("正文已落库", body.includes("必须") && body.includes("财务口径"), body.slice(0, 40)); + check("落库正文不含令牌", !body.includes("token="), body.includes("token=") ? "含 token=" : ""); - // —— 2. 编辑器插入图片(模拟选文件,走的是粘贴/拖拽同一段上传逻辑)—— - await drawer.getByRole("button", { name: "继续编辑" }).click(); - await drawer - .locator('input[type="file"][accept="image/*"]') - .setInputFiles({ name: "票据截图.png", mimeType: "image/png", buffer: PNG }); - await drawer.getByText("![票据截图.png]").or(drawer.locator("textarea")).first().waitFor(); - await page.waitForTimeout(1200); // 等上传返回并写回正文 + // —— 3. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)—— + const uploadInput = drawer.locator('.vditor input[type="file"]'); + await uploadInput.setInputFiles({ name: "票据截图.png", mimeType: "image/png", buffer: PNG }); + await page.waitForTimeout(1500); - await drawer.getByRole("button", { name: "预览" }).click(); - const img = drawer.locator(".prose-dadao img").first(); - await img.waitFor({ timeout: 8000 }); - const src = await img.getAttribute("src"); - check("正文出现图片", Boolean(src?.includes("/raw?token=")), String(src ?? "").slice(0, 48)); + const img = drawer.locator(".vditor-ir img").first(); + await img.waitFor({ timeout: 10000 }); + const src = (await img.getAttribute("src")) ?? ""; + check("上传后编辑器里出现图片", Boolean(src), src.slice(0, 60)); + check("图片走带令牌的 /raw 直链", src.includes("/raw?token="), src.slice(0, 60)); await page.waitForTimeout(800); check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(",")); - // —— 3. 附件面板上传与列表 —— + // —— 4. 附件面板上传与列表 —— await drawer - .locator('input[type="file"]:not([accept="image/*"])') + .locator('input.hidden[type="file"]') .setInputFiles({ name: "报销须知.txt", mimeType: "text/plain", buffer: Buffer.from("抬头必须是公司全称", "utf8"), }); - await drawer.getByText("报销须知.txt").waitFor({ timeout: 8000 }); + await drawer.getByText("报销须知.txt").waitFor({ timeout: 10000 }); check("附件上传后出现在列表", true); - // —— 4. 落库后重新打开仍在(不是只活在内存里)—— + // —— 5. 落库后重新打开仍在(不是只活在内存里)—— await drawer.getByRole("button", { name: "关闭" }).click(); await page.getByRole("button", { name: "整理发票原件", exact: true }).click(); const drawer2 = page.getByRole("dialog"); await drawer2.waitFor(); - await drawer2.getByRole("button", { name: "预览" }).click(); - check( - "重新打开后正文仍是渲染态", - (await drawer2.locator(".prose-dadao strong").count()) > 0, - ); + await page.waitForSelector(".vditor-ir strong", { timeout: 20000 }); + check("重新打开后正文仍是渲染态", (await drawer2.locator(".vditor-ir strong").count()) > 0); let stillThere = true; try { - await drawer2.getByText("报销须知.txt").waitFor({ timeout: 6000 }); + await drawer2.getByText("报销须知.txt").waitFor({ timeout: 8000 }); } catch { stillThere = false; } diff --git a/web/e2e/shots-rich.mjs b/web/e2e/shots-rich.mjs index 5ff2f5a..f62a95e 100644 --- a/web/e2e/shots-rich.mjs +++ b/web/e2e/shots-rich.mjs @@ -1,5 +1,5 @@ /** - * 截图:详情抽屉里的 Markdown 编辑器(写作态 + 预览态)。 + * 截图:详情抽屉里的富文本编辑器(Vditor,边写边看)。 * 只用于留存界面现状,不做断言。 */ import { chromium } from "playwright-core"; @@ -91,13 +91,11 @@ await page.getByRole("button", { name: "全部", exact: true }).click(); await page.getByRole("button", { name: "把十月报销单寄给财务", exact: true }).click(); const drawer = page.getByRole("dialog"); await drawer.waitFor(); -await page.waitForTimeout(600); +// Vditor 是"边写边看",没有单独的预览态:等它把 lute 与正文渲染出来再截图 +await page.waitForSelector(".vditor-ir strong", { timeout: 20000 }); +await page.waitForTimeout(1200); await page.screenshot({ path: "../2.Design/screens/web-editor-write.png" }); -await drawer.getByRole("button", { name: "预览" }).click(); -await page.waitForTimeout(900); -await page.screenshot({ path: "../2.Design/screens/web-editor-preview.png" }); - await context.close(); await browser.close(); console.log("shots saved"); diff --git a/web/package-lock.json b/web/package-lock.json index f799972..66b6fba 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,15 +8,19 @@ "name": "dadaotask-web", "version": "0.1.0", "dependencies": { + "dompurify": "^3.4.17", + "markdown-it": "^15.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "vditor": "^4.0.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "playwright-core": "^1.51.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", @@ -1353,6 +1357,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "http://mirrors.huaweicloud.com/repository/npm/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1402,6 +1413,22 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "3.0.4", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, "node_modules/autoprefixer": { "version": "10.6.2", "resolved": "http://mirrors.huaweicloud.com/repository/npm/autoprefixer/-/autoprefixer-10.6.2.tgz", @@ -1643,6 +1670,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "http://mirrors.huaweicloud.com/repository/npm/dlv/-/dlv-1.1.3.tgz", @@ -1650,6 +1683,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.466", "resolved": "http://mirrors.huaweicloud.com/repository/npm/electron-to-chromium/-/electron-to-chromium-1.5.466.tgz", @@ -1657,6 +1699,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "http://mirrors.huaweicloud.com/repository/npm/es-errors/-/es-errors-1.3.0.tgz", @@ -1971,6 +2025,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "http://mirrors.huaweicloud.com/repository/npm/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1993,6 +2066,39 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-it": { + "version": "15.0.3", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "http://mirrors.huaweicloud.com/repository/npm/merge2/-/merge2-1.4.1.tgz", @@ -2132,6 +2238,19 @@ "node": ">= 6" } }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.32", "resolved": "http://mirrors.huaweicloud.com/repository/npm/postcss/-/postcss-8.5.32.tgz", @@ -2295,6 +2414,15 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "http://mirrors.huaweicloud.com/repository/npm/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2714,6 +2842,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "http://mirrors.huaweicloud.com/repository/npm/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -2752,6 +2886,18 @@ "dev": true, "license": "MIT" }, + "node_modules/vditor": { + "version": "4.0.1", + "resolved": "http://mirrors.huaweicloud.com/repository/npm/vditor/-/vditor-4.0.1.tgz", + "integrity": "sha512-CyajvPNE8fjCgEAzAaSxVEEWwQMJ5FTIVkNn3k+5IRaldqGwPRr8Dx2QLALHGCby8L/AJ6pQXjXiSPUGhHGmDw==", + "license": "MIT", + "dependencies": { + "diff-match-patch": "^1.0.5" + }, + "funding": { + "url": "https://ld246.com/sponsor" + } + }, "node_modules/vite": { "version": "6.4.5", "resolved": "http://mirrors.huaweicloud.com/repository/npm/vite/-/vite-6.4.5.tgz", diff --git a/web/package.json b/web/package.json index dfe81e3..572d449 100644 --- a/web/package.json +++ b/web/package.json @@ -4,21 +4,28 @@ "version": "0.1.0", "type": "module", "scripts": { + "vendor": "node scripts/vendor-vditor.mjs", + "predev": "npm run vendor", "dev": "vite", + "prebuild": "npm run vendor", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "tsc --noEmit" }, "dependencies": { + "dompurify": "^3.4.17", + "markdown-it": "^15.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "vditor": "^4.0.1" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "playwright-core": "^1.51.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", diff --git a/web/scripts/vendor-vditor.mjs b/web/scripts/vendor-vditor.mjs new file mode 100644 index 0000000..e88a802 --- /dev/null +++ b/web/scripts/vendor-vditor.mjs @@ -0,0 +1,48 @@ +/** + * 把 Vditor 的运行时资源同步到 public/vditor。 + * + * 为什么不用它的默认 CDN:Vditor 会按需去 `unpkg.com/vditor@<版本>` 拉 lute(3.7 MB + * 的 Markdown 引擎)、语言包和图标。内网、离线、或者被墙的环境下,编辑器会一直停在 + * 加载状态,而失败信息只有控制台一行 404。 + * + * 这里只复制真正需要的几样,`public/vditor` 不进版本库(见 .gitignore), + * 由 `predev` / `prebuild` 自动生成,升级 Vditor 后自然跟着更新。 + */ + +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const webRoot = join(here, ".."); + +const source = join(webRoot, "node_modules", "vditor", "dist"); +const target = join(webRoot, "public", "vditor", "dist"); + +const files = [ + "js/lute/lute.min.js", + "js/i18n/zh_CN.js", + "js/i18n/en_US.js", + "js/icons/ant.js", + "js/icons/material.js", +]; + +if (!existsSync(source)) { + console.error("[vendor-vditor] 没找到 node_modules/vditor,先跑一次 npm install"); + process.exit(1); +} + +let copied = 0; +for (const rel of files) { + const from = join(source, rel); + if (!existsSync(from)) { + console.warn(`[vendor-vditor] 跳过(包里没有): ${rel}`); + continue; + } + const to = join(target, rel); + mkdirSync(dirname(to), { recursive: true }); + copyFileSync(from, to); + copied += 1; +} + +console.log(`[vendor-vditor] 已同步 ${copied} 个文件到 public/vditor/dist`); diff --git a/web/src/components/RichTextEditor.tsx b/web/src/components/RichTextEditor.tsx index 274f8b1..7f90de5 100644 --- a/web/src/components/RichTextEditor.tsx +++ b/web/src/components/RichTextEditor.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { IVditorInstance } from "vditor/dist/index.js"; + +import { stripAttachmentTokens, tokenizeAttachmentUrls } from "../lib/attachmentUrls"; import { attachmentUrl, uploadFile } from "../lib/client"; -import { Markdown } from "./Markdown"; -import { Bold, Code, Eye, Image as ImageIcon, Italic, Link2, ListIcon, Pencil, Quote } from "./icons"; interface Attachment { id: string; @@ -10,6 +11,7 @@ interface Attachment { } interface Props { + /** 进出的都是"干净"的 Markdown:不含附件令牌,令牌只在编辑器内部临时存在 */ value: string; onChange: (next: string) => void; /** 图片要落到具体条目下,所以这两个是必填 */ @@ -17,230 +19,184 @@ interface Props { itemId: string; placeholder?: string; disabled?: boolean; - rows?: number; + /** 编辑器初始高度(px) */ + minHeight?: number; ariaLabel: string; onError?: (message: string) => void; } /** - * 富文本编辑器。 + * 富文本编辑器:Vditor(MIT,中文优先,Markdown 原生进出)。 * - * 存储格式是 Markdown——为什么不做 contenteditable 的富文本: - * 那样存进去的是 HTML,导出、检索、喂给 LLM 都要再清洗一遍;而 Markdown 天生 - * 既是纯文本又能表达排版,模型读得懂、git diff 也看得懂。 + * 为什么不是自研:我上一版用 textarea + 正则做工具栏,看着能用,但一到 + * 粘贴图片、中文输入法、撤销栈、表格就露馅——这些正是"成熟组件"积累的部分。 * - * 写作时不用记语法:工具栏按钮、Ctrl+B/I、粘贴/拖拽图片,都会就地改写文本。 + * 为什么存储仍用 Markdown:Vditor 的 `ir` 模式给出"边写边看"的观感, + * 但它落盘的是 Markdown 而不是 HTML,导出、检索、喂给 LLM 都还是纯文本。 */ export function RichTextEditor({ value, onChange, wsId, itemId, - placeholder = "写点什么…支持 Markdown:**粗体**、- 列表、```代码```", + placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版", disabled = false, - rows = 6, + minHeight = 240, ariaLabel, onError, }: Props) { - const ref = useRef(null); - const fileRef = useRef(null); - const [preview, setPreview] = useState(false); - const [dragging, setDragging] = useState(false); + const hostRef = useRef(null); + const instRef = useRef(null); + /** 外部最新值(已剥令牌),用来判断"这次变化是不是自己打出来的" */ + const valueRef = useRef(value); + const onChangeRef = useRef(onChange); + const onErrorRef = useRef(onError); + const [ready, setReady] = useState(false); const [uploading, setUploading] = useState(false); - const helpId = useId(); - /** 在光标处插入文本;有选区就包裹它,插完把光标放到合适位置。 */ - const surround = useCallback( - (before: string, after = "", placeholderText = "") => { - const el = ref.current; - if (!el) return; - const { selectionStart: start, selectionEnd: end } = el; - const picked = value.slice(start, end); - const selected = picked || placeholderText; - const next = value.slice(0, start) + before + selected + after + value.slice(end); + valueRef.current = value; + onChangeRef.current = onChange; + onErrorRef.current = onError; + const disabledRef = useRef(disabled); + disabledRef.current = disabled; - onChange(next); - // 等 React 把新值写回 textarea 再摆光标,否则会被旧值覆盖 - requestAnimationFrame(() => { - el.focus(); - if (picked) { - // 有选区:保持包裹后的内容仍被选中,方便继续加别的样式 - el.setSelectionRange(start + before.length, start + before.length + selected.length); - } else { - const cursor = start + before.length + selected.length; - el.setSelectionRange(cursor, cursor); - } - }); - }, - [onChange, value], - ); - - const prefixLines = useCallback( - (prefix: string) => { - const el = ref.current; - if (!el) return; - const start = value.lastIndexOf("\n", el.selectionStart - 1) + 1; - const endIdx = value.indexOf("\n", el.selectionEnd); - const end = endIdx === -1 ? value.length : endIdx; - const block = value.slice(start, end) || "列表项"; - const nextBlock = block - .split("\n") - .map((line) => `${prefix}${line.replace(/^(\s*)([-*+]|\d+[.)])\s+/, "$1")}`) - .join("\n"); - onChange(value.slice(0, start) + nextBlock + value.slice(end)); - requestAnimationFrame(() => el.focus()); - }, - [onChange, value], - ); - - const insertImages = useCallback( - async (files: File[]) => { - const images = files.filter((file) => file.type.startsWith("image/")); - if (images.length === 0) return; - - setUploading(true); - try { - let next = value; - for (const file of images) { - const created = await uploadFile( - `/workspaces/${wsId}/items/${itemId}/attachments`, - file, - ); - const snippet = `\n![${created.filename}](${attachmentUrl(wsId, created.id)})\n`; - next += snippet; - } - onChange(next); - } catch (err) { - onError?.(err instanceof Error ? err.message : "图片上传失败"); - } finally { - setUploading(false); - } - }, - [itemId, onError, onChange, value, wsId], - ); - - // 用 document 级别的粘贴监听兜住"焦点在 textarea 但剪贴板里是图片"的情况 + // 每个条目一个编辑器实例:切条目必须重建,否则撤销栈、高度、图片上传路径都会串 useEffect(() => { - const el = ref.current; - if (!el || disabled) return; + const host = hostRef.current; + if (!host) return; + let disposed = false; - const onPaste = (event: ClipboardEvent) => { - const files = Array.from(event.clipboardData?.files ?? []); - if (files.some((file) => file.type.startsWith("image/"))) { - event.preventDefault(); - void insertImages(files); + void (async () => { + const [{ default: Vditor }] = await Promise.all([ + import("vditor/dist/index.js"), + import("vditor/dist/index.css"), + ]); + if (disposed) return; + + const instance = new Vditor(host, { + cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor) + mode: "ir", + value: tokenizeAttachmentUrls(valueRef.current), + height: minHeight, + placeholder, + theme: "classic", + icon: "ant", + lang: "zh_CN", + cache: { enable: false }, // 草稿由我们自己的「保存」按钮负责,不用它的 localStorage + counter: { enable: false }, + outline: { enable: false }, + toolbarConfig: { pin: true }, + toolbar: [ + "headings", + "bold", + "italic", + "strike", + "link", + "|", + "list", + "ordered-list", + "check", + "quote", + "line", + "code", + "inline-code", + "upload", + "table", + "|", + "undo", + "redo", + "fullscreen", + ], + preview: { + // 关掉代码高亮与公式:它们要额外从 CDN 拉三端脚本,内网/离线会白等 + hljs: { enable: false, lineNumber: false }, + math: { engine: "" }, + markdown: { toc: false, mark: false, footnotes: false }, + }, + upload: { + accept: "image/*", + multiple: true, + handler: async (files: File[]) => { + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) { + onErrorRef.current?.("只能插入图片"); + return null; + } + + setUploading(true); + try { + for (const file of images) { + const created = await uploadFile( + `/workspaces/${wsId}/items/${itemId}/attachments`, + file, + ); + // 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会 + // tip.show(字符串) 当成提示语显示,并不会把内容插进正文。 + instRef.current?.insertMD(`\n![](${attachmentUrl(wsId, created.id)})\n`); + } + return null; + } catch (error) { + onErrorRef.current?.(error instanceof Error ? error.message : "图片上传失败"); + return null; + } finally { + setUploading(false); + } + }, + }, + input: (next: string) => { + // 往外吐干净版本;比较用同一口径,避免 setValue/sync 互相打架 + onChangeRef.current(stripAttachmentTokens(next)); + }, + after: () => { + if (disposed) return; + // 让屏幕阅读器和 e2e 都能按名字找到这块编辑区 + host.querySelector(".vditor-ir [contenteditable]")?.setAttribute("aria-label", ariaLabel); + if (disabledRef.current) instRef.current?.disabled(); + setReady(true); + }, + }); + + instRef.current = instance as unknown as IVditorInstance; + })(); + + return () => { + disposed = true; + setReady(false); + try { + instRef.current?.destroy(); + } catch { + // 销毁失败不该影响切页面 } + instRef.current = null; }; - el.addEventListener("paste", onPaste); - return () => el.removeEventListener("paste", onPaste); - }, [disabled, insertImages]); + // 只跟条目走;其余参数通过 ref 读最新值,避免重建丢焦点 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [itemId, wsId]); - const tools = [ - { label: "粗体", icon: , run: () => surround("**", "**", "粗体") }, - { label: "斜体", icon: , run: () => surround("*", "*", "斜体") }, - { label: "标题", icon: H, run: () => prefixLines("## ") }, - { label: "列表", icon: , run: () => prefixLines("- ") }, - { label: "引用", icon: , run: () => prefixLines("> ") }, - { label: "代码", icon: , run: () => surround("`", "`", "code") }, - { label: "链接", icon: , run: () => surround("[", "](https://)", "链接文字") }, - ]; + // 外部值变化(例如抽屉刚 hydrate 完)→ 写回编辑器,但别打断正在输入的人 + useEffect(() => { + const inst = instRef.current; + if (!inst) return; + const current = stripAttachmentTokens(inst.getValue()); + if (current !== value) { + inst.setValue(tokenizeAttachmentUrls(value), true); + } + }, [value, ready]); + + useEffect(() => { + const inst = instRef.current; + if (!inst) return; + if (disabled) inst.disabled(); + else inst.enable(); + }, [disabled, ready]); return ( -
-
- {tools.map((tool) => ( - - ))} - - - -
- - { - void insertImages(Array.from(event.target.files ?? [])); - event.target.value = ""; - }} - /> - - {preview ? ( -
- {value.trim() ? :

还没有内容。

} -
- ) : ( -