import { useEffect, useRef, useState } from "react"; import type { IVditorInstance } from "vditor/dist/index.js"; import { applyAttachmentUrls, stripAttachmentTokens, type UrlResources } from "../lib/attachmentUrls"; import { canonicalAttachmentPath, uploadFile } from "../lib/client"; interface Attachment { id: string; filename: string; /** 后端签发的直链(带短期签名),用来在编辑器里直接显示刚传的图 */ raw_url?: string; } interface Props { /** 进出的都是"干净"的 Markdown:不含附件令牌,令牌只在编辑器内部临时存在 */ value: string; onChange: (next: string) => void; /** 图片要落到具体条目下,所以这两个是必填 */ wsId: string; /** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */ itemId?: string; /** * 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。 * 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。 */ ensureItemId?: () => Promise; placeholder?: string; disabled?: boolean; /** 编辑器初始高度(px) */ minHeight?: number; ariaLabel: string; onError?: (message: string) => void; /** 附件 id → 可访问地址;编辑器渲染图片时优先用它(签名比令牌安全且会轮换) */ resources?: UrlResources; /** 编辑器里贴图上传成功后通知外层,好让附件面板立刻多一条 */ onUploaded?: (attachment: Attachment) => void; } /** * 富文本编辑器:Vditor(MIT,中文优先,Markdown 原生进出)。 * * 为什么不是自研:我上一版用 textarea + 正则做工具栏,看着能用,但一到 * 粘贴图片、中文输入法、撤销栈、表格就露馅——这些正是"成熟组件"积累的部分。 * * 为什么存储仍用 Markdown:Vditor 的 `ir` 模式给出"边写边看"的观感, * 但它落盘的是 Markdown 而不是 HTML,导出、检索、喂给 LLM 都还是纯文本。 */ export function RichTextEditor({ value, onChange, wsId, itemId, ensureItemId, placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版", disabled = false, minHeight = 240, ariaLabel, onError, resources, onUploaded, }: Props) { const hostRef = useRef(null); const instRef = useRef(null); /** 外部最新值(已剥令牌),用来判断"这次变化是不是自己打出来的" */ const valueRef = useRef(value); const onChangeRef = useRef(onChange); const onErrorRef = useRef(onError); const resourcesRef = useRef(resources); const onUploadedRef = useRef(onUploaded); const ensureItemIdRef = useRef(ensureItemId); const [ready, setReady] = useState(false); const [uploading, setUploading] = useState(false); valueRef.current = value; onChangeRef.current = onChange; onErrorRef.current = onError; resourcesRef.current = resources; onUploadedRef.current = onUploaded; ensureItemIdRef.current = ensureItemId; const disabledRef = useRef(disabled); disabledRef.current = disabled; // 每个条目一个编辑器实例:切条目必须重建,否则撤销栈、高度、图片上传路径都会串 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: applyAttachmentUrls(valueRef.current, resourcesRef.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", // 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿 ...(itemId || ensureItemId ? ["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[]) => { // 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传 let targetId = itemId; if (!targetId) { const ensured = await ensureItemIdRef.current?.(); if (!ensured) { // 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音 if (!ensureItemIdRef.current) { onErrorRef.current?.("先保存这条内容,再回来贴图或传附件"); } return null; } targetId = ensured; } 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/${targetId}/attachments`, file, ); // 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会 // tip.show(字符串) 当成提示语显示,并不会把内容插进正文。 const src = created.raw_url ?? canonicalAttachmentPath(wsId, created.id); instRef.current?.insertMD(`\n![](${src})\n`); onUploadedRef.current?.(created); } 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; }; // 只跟条目走;其余参数通过 ref 读最新值,避免重建丢焦点 // eslint-disable-next-line react-hooks/exhaustive-deps }, [itemId, wsId]); // 外部值变化(例如抽屉刚 hydrate 完)→ 写回编辑器,但别打断正在输入的人 useEffect(() => { const inst = instRef.current; if (!inst) return; const current = stripAttachmentTokens(inst.getValue()); if (current !== value) { inst.setValue(applyAttachmentUrls(value, resourcesRef.current), true); } }, [value, ready]); useEffect(() => { const inst = instRef.current; if (!inst) return; if (disabled) inst.disabled(); else inst.enable(); }, [disabled, ready]); return (

{uploading ? "正在上传图片…" : itemId ? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂" : ensureItemId ? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去" : "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}

); }