Files
DadaoTask/web/src/lib/markdown.ts
T
dadao 17be9d1d42
CI / backend (push) Canceled after 0s
feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
2026-10-29 03:32:53 +08:00

78 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Markdown 渲染:markdown-it + DOMPurify。
*
* 这两块不自己写。上一次我用正则拼标签,能显示粗体和列表,但表格、嵌套列表、
* 代码块语言标注、链接里的括号都会出错——那是通用解析器的活,不是产品特有的逻辑。
*
* 安全策略:markdown-it 关掉原始 HTML(`html: false`),DOMPurify 再过一遍。
* 两道防线看着重复,但它们的失败模式不一样:前者防"作者写进 HTML",
* 后者防"链接/图片协议"这类由解析器生成的属性被滥用。
*/
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import { applyAttachmentUrls, type UrlResources } 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);
};
const defaultImage =
md.renderer.rules.image ??
((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
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);
};
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
export function renderMarkdown(source: string, resources?: UrlResources): string {
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
});
// 库里存的是干净的直链,渲染这一层按附件列表换成带签名的地址(见 lib/attachmentUrls.ts)
return applyAttachmentUrls(safe, resources);
}
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
export function plainSummary(source: string, limit = 90): string {
const text = (source ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[#>*_~`|-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return text.length > limit ? `${text.slice(0, limit)}…` : text;
}