feat(web): 富文本编辑器换成 Vditor,不再自研轮子
CI / backend (push) Canceled after 0s

上一版我自己写了 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:
dadao
2026-10-29 02:30:10 +08:00
parent 7a7ea932a4
commit fbdf54cb4c
18 changed files with 630 additions and 419 deletions
+156 -200
View File
@@ -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);
}
});
},
[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<Attachment>(
`/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<Attachment>(
`/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: <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>
);
-1
View File
@@ -498,7 +498,6 @@ export function TaskDetail({
value={body}
onChange={setBody}
onError={(message) => setError(message)}
rows={8}
disabled={busy}
/>
</div>
+50
View File
@@ -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;
}
+39
View File
@@ -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
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
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;
}
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
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
// ![alt](url) 与 [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 的字符串。 */
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>`);
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);
};
return out.join("\n");
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
export function renderMarkdown(source: string): string {
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;
-1
View File
@@ -460,7 +460,6 @@ function KnowledgeDrawer({
value={body}
onChange={setBody}
onError={(message) => setError(message)}
rows={16}
disabled={!canEdit}
/>
</div>
+47
View File
@@ -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);
}
}