上一版我自己写了 textarea + 正则工具栏,看着能用,一到粘贴图片、
中文输入法、撤销栈就露馅——这几样正是成熟组件积累的部分。按要求改成品。
- 编辑器开关 def: RichTextEditor = Vditor 4.0.1(MIT,ir 模式)
* 运行时资源自托管:web/scripts/vendor-vditor.mjs 把 lute/i18n/icons
同步到 public/vditor,predev/prebuild 自动执行,不走 unpkg CDN
(内网会一直卡在加载)
- 只读渲染换 markdown-it + dompurify,删掉自写的正则渲染器
- 附件直链的令牌只在内存里存在:落库前 stripAttachmentTokens,
渲染/装载前补回(<img> 带不了 Authorization 头,raw?token= 是必要折中)
- Vditor 不会替我们插入 upload.handler 的返回值(只当提示语 tip.show),
所以上传完自己 insertMD
验证:pytest 70 passed;e2e richtext 13 项、smoke 7、tickets 8、
knowledge 7 全绿;tsc 0 错;主包 299 kB/gzip 75 kB,Vditor 独立
chunk 385 kB 按需加载。
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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] 粘贴与拖拽上传图片,自动在光标处插入 `)`
|
||||
- [x] `RichTextEditor` = Vditor 封装:`mode: "ir"`、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉 `cache`(草稿由我们自己的保存按钮负责)
|
||||
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `)`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
|
||||
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 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
|
||||
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 384.73 kB │ gzip 135.82 kB(按需加载,只进详情抽屉)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
@@ -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 |
|
||||
|
||||
+54
-39
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* 富文本与附件 e2e:备注能写 Markdown、贴图能上传并渲染、附件能传能列。
|
||||
* 富文本与附件 e2e:备注用 Vditor 写 Markdown、贴图能上传并显示、附件能传能列。
|
||||
*
|
||||
* 这里刻意走真浏览器(不是只测接口)——因为要验的正是"编辑器把内容写回后,
|
||||
* 渲染出来是不是那个样子",以及 <img src> 带 token 的那条路能不能真的取到图。
|
||||
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
|
||||
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+147
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const instRef = useRef<IVditorInstance | null>(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);
|
||||
}
|
||||
});
|
||||
// 每个条目一个编辑器实例:切条目必须重建,否则撤销栈、高度、图片上传路径都会串
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let disposed = false;
|
||||
|
||||
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 },
|
||||
},
|
||||
[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[]) => {
|
||||
upload: {
|
||||
accept: "image/*",
|
||||
multiple: true,
|
||||
handler: async (files: File[]) => {
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (images.length === 0) return;
|
||||
if (images.length === 0) {
|
||||
onErrorRef.current?.("只能插入图片");
|
||||
return null;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
let next = value;
|
||||
for (const file of images) {
|
||||
const created = await uploadFile<Attachment>(
|
||||
`/workspaces/${wsId}/items/${itemId}/attachments`,
|
||||
file,
|
||||
);
|
||||
const snippet = `\n})\n`;
|
||||
next += snippet;
|
||||
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
||||
// tip.show(字符串) 当成提示语显示,并不会把内容插进正文。
|
||||
instRef.current?.insertMD(`\n})\n`);
|
||||
}
|
||||
onChange(next);
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "图片上传失败");
|
||||
return null;
|
||||
} catch (error) {
|
||||
onErrorRef.current?.(error instanceof Error ? error.message : "图片上传失败");
|
||||
return null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[itemId, onError, onChange, value, wsId],
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
// 用 document 级别的粘贴监听兜住"焦点在 textarea 但剪贴板里是图片"的情况
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || disabled) return;
|
||||
instRef.current = instance as unknown as IVditorInstance;
|
||||
})();
|
||||
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.some((file) => file.type.startsWith("image/"))) {
|
||||
event.preventDefault();
|
||||
void insertImages(files);
|
||||
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: <Bold size={15} />, run: () => surround("**", "**", "粗体") },
|
||||
{ label: "斜体", icon: <Italic size={15} />, run: () => surround("*", "*", "斜体") },
|
||||
{ label: "标题", icon: <span className="text-[13px] font-semibold">H</span>, run: () => prefixLines("## ") },
|
||||
{ label: "列表", icon: <ListIcon size={15} />, run: () => prefixLines("- ") },
|
||||
{ label: "引用", icon: <Quote size={15} />, run: () => prefixLines("> ") },
|
||||
{ label: "代码", icon: <Code size={15} />, run: () => surround("`", "`", "code") },
|
||||
{ label: "链接", icon: <Link2 size={15} />, 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 (
|
||||
<div className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-line-100 pb-1.5">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
title={tool.label}
|
||||
aria-label={tool.label}
|
||||
disabled={disabled || preview}
|
||||
onClick={tool.run}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
title="插入图片"
|
||||
aria-label="插入图片"
|
||||
disabled={disabled || preview || uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
|
||||
>
|
||||
<ImageIcon size={15} />
|
||||
</button>
|
||||
<span className="mx-1 h-4 w-px bg-line-100" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-2xs font-medium text-ink-500 transition hover:bg-paper-200 hover:text-ink-800"
|
||||
aria-pressed={preview}
|
||||
>
|
||||
{preview ? <Pencil size={13} /> : <Eye size={13} />}
|
||||
{preview ? "继续编辑" : "预览"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void insertImages(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<div className="min-h-[96px] rounded-lg border border-line-100 bg-paper-50 px-3 py-2.5">
|
||||
{value.trim() ? <Markdown source={value} /> : <p className="text-sm text-ink-400">还没有内容。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={helpId}
|
||||
className="input min-h-[96px] resize-y border-0 bg-transparent px-0 py-2 font-mono text-[13px] leading-6 focus:ring-0"
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void insertImages(files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "b") {
|
||||
event.preventDefault();
|
||||
surround("**", "**", "粗体");
|
||||
} else if (key === "i") {
|
||||
event.preventDefault();
|
||||
surround("*", "*", "斜体");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p id={helpId} className="mt-1 text-2xs text-ink-400">
|
||||
{uploading ? "正在上传图片…" : "可直接粘贴或拖入图片;Ctrl+B 粗体、Ctrl+I 斜体"}
|
||||
<div className="rich-text">
|
||||
<div ref={hostRef} />
|
||||
<p className="mt-1 text-2xs text-ink-400">
|
||||
{uploading
|
||||
? "正在上传图片…"
|
||||
: "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -498,7 +498,6 @@ export function TaskDetail({
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onError={(message) => setError(message)}
|
||||
rows={8}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -277,3 +277,53 @@
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Vditor 富文本:只做"贴合本产品"的覆写,不改它的行为 ---- */
|
||||
.rich-text .vditor {
|
||||
border: 1px solid theme("colors.line.200");
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.rich-text .vditor:focus-within {
|
||||
border-color: theme("colors.primary.400");
|
||||
box-shadow: 0 0 0 3px theme("colors.primary.100");
|
||||
}
|
||||
.rich-text .vditor-toolbar {
|
||||
background: theme("colors.paper.50");
|
||||
border-bottom: 1px solid theme("colors.line.100");
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.rich-text .vditor-toolbar__item button {
|
||||
color: theme("colors.ink.500");
|
||||
}
|
||||
.rich-text .vditor-toolbar__item button:hover,
|
||||
.rich-text .vditor-toolbar__item button.vditor-menu--current {
|
||||
color: theme("colors.primary.700");
|
||||
background: theme("colors.primary.50");
|
||||
}
|
||||
.rich-text .vditor-reset {
|
||||
color: theme("colors.ink.800");
|
||||
font-family: theme("fontFamily.sans");
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.75;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.rich-text .vditor-ir pre.vditor-reset {
|
||||
background: #fff;
|
||||
}
|
||||
.rich-text .vditor-ir pre.vditor-reset:empty::before {
|
||||
color: theme("colors.ink.300");
|
||||
}
|
||||
.rich-text .vditor-reset img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rich-text .vditor-panel,
|
||||
.rich-text .vditor-hint {
|
||||
border-color: theme("colors.line.200");
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px -12px rgba(31, 42, 40, 0.28);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 附件直链的令牌处理。
|
||||
*
|
||||
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`。
|
||||
* 原因有两条——
|
||||
* 1. `<img>` 发不出 Authorization 头,图片只能靠 query 里的 token 鉴权;
|
||||
* 2. token 会过期。要是把它写进正文,几天后整篇文档的图片全变红叉,
|
||||
* 而且团队里每个人拿到的 token 还不一样,同一个字段没法共用。
|
||||
*
|
||||
* 所以规则是:**存的时候剥掉,看的时候现加**。token 永远是当前会话的。
|
||||
*/
|
||||
|
||||
import { session } from "./client";
|
||||
|
||||
const UUID = "[0-9a-fA-F-]{36}";
|
||||
/** 已经带上(或没带)token 的附件直链 */
|
||||
const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/${UUID}/raw)(?:\\?token=[^)\"'\\s]*)?`, "g");
|
||||
|
||||
function tokenSuffix(): string {
|
||||
const token = session.access;
|
||||
return token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
}
|
||||
|
||||
/** 保存前调用:把正文里所有 /raw?token=... 的令牌去掉。 */
|
||||
export function stripAttachmentTokens(source: string): string {
|
||||
return (source ?? "").replace(RAW_URL, (_all, url: string) => url);
|
||||
}
|
||||
|
||||
/** 渲染或加载进编辑器前调用:给所有 /raw 链接补上当前令牌。 */
|
||||
export function tokenizeAttachmentUrls(source: string): string {
|
||||
const suffix = tokenSuffix();
|
||||
if (!suffix) return stripAttachmentTokens(source);
|
||||
return (source ?? "").replace(RAW_URL, (_all, url: string) => `${url}${suffix}`);
|
||||
}
|
||||
|
||||
/** 只处理 HTML 里的 img/src 与 a/href(已渲染的预览用)。 */
|
||||
export function tokenizeAttachmentHtml(html: string): string {
|
||||
return tokenizeAttachmentUrls(html);
|
||||
}
|
||||
+58
-161
@@ -1,179 +1,76 @@
|
||||
/**
|
||||
* 极简 Markdown 渲染器。
|
||||
* Markdown 渲染:markdown-it + DOMPurify。
|
||||
*
|
||||
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
|
||||
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
|
||||
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
|
||||
* 这两块不自己写。上一次我用正则拼标签,能显示粗体和列表,但表格、嵌套列表、
|
||||
* 代码块语言标注、链接里的括号都会出错——那是通用解析器的活,不是产品特有的逻辑。
|
||||
*
|
||||
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
|
||||
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
|
||||
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
|
||||
* 安全策略:markdown-it 关掉原始 HTML(`html: false`),DOMPurify 再过一遍。
|
||||
* 两道防线看着重复,但它们的失败模式不一样:前者防"作者写进 HTML",
|
||||
* 后者防"链接/图片协议"这类由解析器生成的属性被滥用。
|
||||
*/
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
import DOMPurify from "dompurify";
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
import { tokenizeAttachmentHtml } from "./attachmentUrls";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false, // 不解析原始 HTML
|
||||
linkify: true, // 裸 URL 自动成链接
|
||||
breaks: true, // 换行即换行(中文写作里这是预期行为)
|
||||
typographer: false,
|
||||
});
|
||||
|
||||
/** 站内相对地址、http(s)、mailto 之外的协议一律拦掉。 */
|
||||
const SAFE_PROTOCOL = /^(?:https?:|mailto:|\/|#)/i;
|
||||
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ??
|
||||
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
|
||||
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const href = String(tokens[idx].attrGet("href") ?? "");
|
||||
if (!SAFE_PROTOCOL.test(href)) {
|
||||
tokens[idx].attrSet("href", "#");
|
||||
}
|
||||
// 外链统一新窗口打开;站内链接保持原行为
|
||||
if (/^https?:/i.test(href)) {
|
||||
tokens[idx].attrSet("target", "_blank");
|
||||
tokens[idx].attrSet("rel", "noreferrer noopener");
|
||||
}
|
||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
|
||||
}
|
||||
const defaultImage =
|
||||
md.renderer.rules.image ??
|
||||
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
|
||||
|
||||
/** 只放行 http/https/mailto 与站内相对地址,挡掉 javascript: 这类伪协议。 */
|
||||
function safeUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^(https?:\/\/|mailto:|\/)/i.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
||||
const src = String(tokens[idx].attrGet("src") ?? "");
|
||||
if (!SAFE_PROTOCOL.test(src)) {
|
||||
tokens[idx].attrSet("src", "");
|
||||
}
|
||||
tokens[idx].attrSet("loading", "lazy");
|
||||
return defaultImage(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
|
||||
function inline(raw: string): string {
|
||||
let text = escapeHtml(raw);
|
||||
|
||||
// 行内代码先摘出来,避免里面的 * 和 _ 被当成强调
|
||||
const codes: string[] = [];
|
||||
text = text.replace(/`([^`]+)`/g, (_m, code: string) => {
|
||||
codes.push(code);
|
||||
return `\u0000CODE${codes.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
text = text
|
||||
//  与 [text](url)
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
return `<img src="${href}" alt="${alt}" loading="lazy" />`;
|
||||
})
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const rel = external ? ' target="_blank" rel="noreferrer noopener"' : "";
|
||||
return `<a href="${href}"${rel}>${label}</a>`;
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(?:^|\s)__([^_]+)__(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/(?:^|\s)_([^_]+)_(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
||||
|
||||
// 还原行内代码(内容已在上面转义过,这里只负责套标签)
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, idx: string) => `<code>${codes[Number(idx)]}</code>`);
|
||||
}
|
||||
|
||||
/** 渲染成可直接塞进 dangerouslySetInnerHTML 的字符串。 */
|
||||
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// 代码块:内容原样保留,只做转义,不做任何行内解析
|
||||
const fence = line.match(/^\s*```(\w*)\s*$/);
|
||||
if (fence) {
|
||||
const lang = fence[1];
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1; // 跳过结束围栏(文件末尾没闭合也能渲染)
|
||||
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
out.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*$/.test(line)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
|
||||
out.push("<hr />");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 引用:连续以 > 开头的行合并成一个 blockquote
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
body.push(lines[i].replace(/^\s*>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<blockquote>${inline(body.join(" "))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 列表:同类型的连续行归一组,嵌套只支持一层(够用,且不会渲染错)
|
||||
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
||||
const ordered = line.match(/^(\s*)\d+[.)]\s+(.*)$/);
|
||||
if (bullet || ordered) {
|
||||
const isOrdered = Boolean(ordered);
|
||||
const items: string[] = [];
|
||||
const pattern = isOrdered ? /^(\s*)\d+[.)]\s+(.*)$/ : /^(\s*)[-*+]\s+(.*)$/;
|
||||
while (i < lines.length) {
|
||||
const m = lines[i].match(pattern);
|
||||
if (!m) break;
|
||||
const [, padding, content] = m;
|
||||
// 缩进两格以上视为子项,用一段加粗缩进表示(不生成嵌套 ol/ul,避免打错标签)
|
||||
items.push(
|
||||
padding.length >= 2
|
||||
? `<li class="ml-4">${inline(content)}</li>`
|
||||
: `<li>${inline(content)}</li>`,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
const tag = isOrdered ? "ol" : "ul";
|
||||
out.push(`<${tag}>${items.join("")}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落:吃到下一个空行或下一个块级语法为止
|
||||
const paragraph: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i];
|
||||
if (
|
||||
/^\s*$/.test(current) ||
|
||||
/^\s*```/.test(current) ||
|
||||
/^(#{1,4})\s+/.test(current) ||
|
||||
/^\s*>\s?/.test(current) ||
|
||||
/^\s*[-*+]\s+/.test(current) ||
|
||||
/^\s*\d+[.)]\s+/.test(current)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraph.push(current);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<p>${inline(paragraph.join("\n")).replace(/\n/g, "<br />")}</p>`);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
|
||||
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
|
||||
});
|
||||
// 库里存的是不带令牌的直链,渲染这一层再补上(见 lib/attachmentUrls.ts)
|
||||
return tokenizeAttachmentHtml(safe);
|
||||
}
|
||||
|
||||
/** 纯文本摘要,用于列表页显示(去掉语法符号)。 */
|
||||
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
|
||||
export function plainSummary(source: string, limit = 90): string {
|
||||
const text = source
|
||||
const text = (source ?? "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#>*_~`-]/g, " ")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#>*_~`|-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
|
||||
@@ -460,7 +460,6 @@ function KnowledgeDrawer({
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onError={(message) => setError(message)}
|
||||
rows={16}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 只给 vditor 的 deep import 声明一个最小类型。
|
||||
*
|
||||
* 不直接用 `import type Vditor from "vditor"`:它的 dist/index.d.ts 里有一句
|
||||
* `import "./assets/less/index.less"`,tsc 会去解析 less 文件而报错。我们只用到
|
||||
* 几个方法,自己声明反而更稳,也顺便把"我们用到的面"钉死在这里。
|
||||
*/
|
||||
declare module "vditor/dist/index.js" {
|
||||
export interface IVditorOptions {
|
||||
mode?: "ir" | "wysiwyg" | "sv";
|
||||
value?: string;
|
||||
height?: number | string;
|
||||
placeholder?: string;
|
||||
theme?: "classic" | "dark";
|
||||
icon?: string;
|
||||
cdn?: string;
|
||||
lang?: string;
|
||||
cache?: { enable?: boolean; id?: string };
|
||||
counter?: { enable?: boolean };
|
||||
toolbar?: Array<string | { name: string; tip?: string }>;
|
||||
toolbarConfig?: { pin?: boolean; hide?: boolean };
|
||||
preview?: Record<string, unknown>;
|
||||
outline?: { enable?: boolean };
|
||||
upload?: {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
handler?: (files: File[]) => string | null | Promise<string | null>;
|
||||
};
|
||||
input?: (value: string) => void;
|
||||
after?: () => void;
|
||||
}
|
||||
|
||||
export interface IVditorInstance {
|
||||
getValue(): string;
|
||||
setValue(markdown: string, clearStack?: boolean): void;
|
||||
insertMD(markdown: string): void;
|
||||
disabled(): void;
|
||||
enable(): void;
|
||||
destroy(): void;
|
||||
disabledCache(): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export default class Vditor {
|
||||
constructor(element: string | HTMLElement, options?: IVditorOptions);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user