Skip to content

Commit 5b67c77

Browse files
committed
fix(note): 笔记预览时间戳锚点 chip 化——renderTimestampAnchors 接入两个轻量渲染器
- NotePreviewView.renderMarkdown / RefineWorkbench.renderMd 预览阶段仍原文输出 [⏱ 00:00]([[ts:233]])(v0.11.5 chip 只落了正式 NoteMarkdown) - html.ts 新增 renderTimestampAnchors:已转义文本上先章头后段落替换,输出与 正式笔记同款可跳转 chip(teal/虚线底/title) - 新增 5 单测:段落 chip/章头 chip/无锚点穿透/转义-替换次序注入安全/非数字分钟
1 parent 52f4958 commit 5b67c77

4 files changed

Lines changed: 92 additions & 14 deletions

File tree

‎app/src/components/NotePreviewView.tsx‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { useCallback, useEffect, useState } from "react";
1414
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
1515
import type { Note, NoteFilterResult, TextFilterDecision, TextFilterReview, TextFilterStatus } from "../types";
16-
import { escapeHtml } from "../utils/html";
16+
import { escapeHtml, renderTimestampAnchors } from "../utils/html";
1717
import AiRefineCard from "./AiRefineCard";
1818

1919
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 };
@@ -33,7 +33,9 @@ const REASON_LABEL: Record<string, string> = {
3333

3434
/** 轻量 Markdown 渲染(标题/段落/列表/屏配图——笔记正文结构有限,避免引渲染库;
3535
* 所有文本经 escapeHtml(utils/html.ts 单一定义源)转义——本地内容仍按不可信输入处理;
36-
* v0.7.3:`![alt](session-images/...)` 配图行 → 本地图(baseUrl + convertFileSrc)) */
36+
* v0.7.3:`![alt](session-images/...)` 配图行 → 本地图(baseUrl + convertFileSrc))
37+
* v0.12.0:时间戳锚点 `[⏱ MM:SS]([[ts:ms]])`/章节锚点 → 芯片(renderTimestampAnchors
38+
* ——修复笔记录库前预览仍显示原始锚点文本的真机验收问题) */
3739
function renderMarkdown(md: string, imageBaseUrl: string, dataDir: string): string {
3840
return md
3941
.split("\n")
@@ -57,15 +59,16 @@ function renderMarkdown(md: string, imageBaseUrl: string, dataDir: string): stri
5759
// convertFileSrc 虽已编码但直出分支无编码,统一 escapeHtml 兜底
5860
return `<img src="${escapeHtml(src)}" alt="${escapeHtml(img[1])}" loading="lazy" style="max-width:260px;border-radius:6px;border:1px solid #e5e7eb;margin:4px 0" />`;
5961
}
60-
if (line.startsWith("# ")) return `<h2 style="font-size:15px;margin:10px 0 4px">${escapeHtml(line.slice(2))}</h2>`;
61-
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:8px 0 4px;color:#0f766e">${escapeHtml(line.slice(3))}</h3>`;
62+
// v0.12.0:先转义再替换锚点(锚点语法字符不在转义集,匹配安全)
63+
if (line.startsWith("# ")) return `<h2 style="font-size:15px;margin:10px 0 4px">${renderTimestampAnchors(escapeHtml(line.slice(2)))}</h2>`;
64+
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:8px 0 4px;color:#0f766e">${renderTimestampAnchors(escapeHtml(line.slice(3)))}</h3>`;
6265
// v0.7.5(REQ-170):会话异常警示行(Markdown 引用行 → 警示块)
6366
if (line.startsWith("> ")) {
64-
return `<div style="font-size:12px;color:#b45309;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:6px 10px;margin:6px 0">${escapeHtml(line.slice(2))}</div>`;
67+
return `<div style="font-size:12px;color:#b45309;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:6px 10px;margin:6px 0">${renderTimestampAnchors(escapeHtml(line.slice(2)))}</div>`;
6568
}
66-
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${escapeHtml(line.slice(2))}</div>`;
69+
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${renderTimestampAnchors(escapeHtml(line.slice(2)))}</div>`;
6770
if (line.trim() === "") return "";
68-
return `<p style="font-size:13px;color:#374151;margin:4px 0">${escapeHtml(line)}</p>`;
71+
return `<p style="font-size:13px;color:#374151;margin:4px 0">${renderTimestampAnchors(escapeHtml(line))}</p>`;
6972
})
7073
.join("");
7174
}

‎app/src/components/RefineWorkbench.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { useCallback, useEffect, useRef, useState } from "react";
1010
import { invoke } from "@tauri-apps/api/core";
1111
import type { AiRefineResult, WorkbenchData } from "../types";
12-
import { escapeHtml } from "../utils/html";
12+
import { escapeHtml, renderTimestampAnchors } from "../utils/html";
1313

1414
const overlay: React.CSSProperties = {
1515
position: "fixed", inset: 0, zIndex: 999,
@@ -27,12 +27,14 @@ const headerBtn: React.CSSProperties = {
2727

2828
function renderMd(md: string): string {
2929
return md.split("\n").map((line) => {
30-
if (line.startsWith("# ")) return `<h2 style="font-size:14px;margin:8px 0 3px">${escapeHtml(line.slice(2))}</h2>`;
31-
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:6px 0 2px;color:#0f766e">${escapeHtml(line.slice(3))}</h3>`;
32-
if (line.startsWith("### ")) return `<h4 style="font-size:12px;margin:4px 0 2px;color:#374151">${escapeHtml(line.slice(4))}</h4>`;
33-
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${escapeHtml(line.slice(2))}</div>`;
30+
// v0.12.0:先转义再替换时间戳锚点(章节锚点 `## 标题 [[⏱ ...]]` 与段落锚点;
31+
// 精修版回挂的章节锚点此前以原始 markdown 文本显示——真机验收修复)
32+
if (line.startsWith("# ")) return `<h2 style="font-size:14px;margin:8px 0 3px">${renderTimestampAnchors(escapeHtml(line.slice(2)))}</h2>`;
33+
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:6px 0 2px;color:#0f766e">${renderTimestampAnchors(escapeHtml(line.slice(3)))}</h3>`;
34+
if (line.startsWith("### ")) return `<h4 style="font-size:12px;margin:4px 0 2px;color:#374151">${renderTimestampAnchors(escapeHtml(line.slice(4)))}</h4>`;
35+
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${renderTimestampAnchors(escapeHtml(line.slice(2)))}</div>`;
3436
if (line.trim() === "") return "";
35-
return `<p style="font-size:12px;color:#374151;margin:2px 0">${escapeHtml(line)}</p>`;
37+
return `<p style="font-size:12px;color:#374151;margin:2px 0">${renderTimestampAnchors(escapeHtml(line))}</p>`;
3638
}).join("");
3739
}
3840

‎app/src/utils/html.test.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
*
44
* Why 重点测属性注入:OCR/ASR 文本既入正文上下文也入属性值上下文,
55
* `" onerror="` 类载荷是属性逃逸的最典型形态。
6+
* v0.12.0 补:renderTimestampAnchors(时间戳回链锚点渲染芯片——预览/工作台
7+
* 轻量渲染器不再显示原始 `[⏱ 00:00]([[ts:233]])` markdown 文本)。
68
*/
79
import { describe, expect, it } from "vitest";
8-
import { escapeHtml } from "./html";
10+
import { escapeHtml, renderTimestampAnchors } from "./html";
911

1012
describe("escapeHtml", () => {
1113
it("五类特殊字符全部转义", () => {
@@ -50,3 +52,52 @@ describe("escapeHtml", () => {
5052
expect(out).toBe("&lt;&amp;");
5153
});
5254
});
55+
56+
describe("renderTimestampAnchors", () => {
57+
it("段落锚点渲染为芯片(不再显示原始 markdown)", () => {
58+
// Arrange:规则版段落锚点(段首)——真机验收泄漏形态
59+
const input = escapeHtml(`[⏱ 00:00]([[ts:233]]) 项目启动是一个艺术。`);
60+
// Act
61+
const out = renderTimestampAnchors(input);
62+
// Assert:芯片出现、原始锚点文本消失
63+
expect(out).toContain("⏱ 00:00");
64+
expect(out).not.toContain("[⏱ 00:00]([[ts:233]])");
65+
});
66+
67+
it("章节锚点(外带包裹括号)渲染为芯片", () => {
68+
// Arrange:`## 标题 [[⏱ 00:09]([[ts:9000]])]` 的标题文本部分
69+
const input = escapeHtml(`标题 [[⏱ 00:09]([[ts:9000]])]`);
70+
// Act
71+
const out = renderTimestampAnchors(input);
72+
// Assert:芯片 + 标题保留;包裹括号与原始锚点均不残留
73+
expect(out).toContain("⏱ 00:09");
74+
expect(out).not.toContain("[[ts:9000]]");
75+
expect(out).not.toContain("]]([[");
76+
});
77+
78+
it("无锚点文本原样返回", () => {
79+
const out = renderTimestampAnchors(escapeHtml(`普通文本没有锚点 123`));
80+
expect(out).toBe("普通文本没有锚点 123");
81+
});
82+
83+
it("先转义再替换——恶意 HTML 不产生可解析标签(注入面为零)", () => {
84+
// Arrange:恶意字幕尝试在锚点旁注入脚本(锚点语法字符均非 HTML 特殊字符,
85+
// 转义不改变匹配形态;但 `<script>` 已被转义)
86+
const input = escapeHtml(`[⏱ 00:00]([[ts:233]]) <script>alert(1)</script> & "x"`);
87+
// Act
88+
const out = renderTimestampAnchors(input);
89+
// Assert:芯片正常渲染 + 脚本不残留可解析标签 + 特殊字符保持转义
90+
expect(out).toContain("⏱ 00:00");
91+
expect(out).not.toContain("<script>");
92+
expect(out).toContain("&lt;script&gt;");
93+
expect(out).toContain("&quot;x&quot;");
94+
});
95+
96+
it("冒号注入不破坏芯片(mm:ss 仅数字化)", () => {
97+
// Arrange:文本中夹带仿锚点形态(非数字分钟)——不得误判
98+
const input = escapeHtml(`文本 [⏱ 这是一个说明]([[ts:1]]) 后续`);
99+
// Act:正则要求分钟为数字——仿形态不匹配,保持转义原文
100+
const out = renderTimestampAnchors(input);
101+
expect(out).toBe(input);
102+
});
103+
});

‎app/src/utils/html.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,25 @@ export function escapeHtml(s: string): string {
1616
.replace(/"/g, "&quot;")
1717
.replace(/'/g, "&#39;");
1818
}
19+
20+
/**
21+
* 时间戳回链锚点 → 芯片 HTML(与 NoteMarkdown 的 a 组件同视觉;v0.12.0 补完成)。
22+
*
23+
* @ai-context: 轻量渲染器(NotePreviewView/RefineWorkbench)此前不识别锚点语
24+
* 法——`[⏱ 00:00]([[ts:233]])` 被 escapeHtml 后原样显示为文本
25+
* (真机验收「笔记预览仍有 [⏱ 00:00]([[ts:233]])」即此)。本函数
26+
* 在**已转义文本**上做正则替换:锚点语法字符(`[ ] ( ) :` 数字)
27+
* 均未被 escapeHtml 转义,模式匹配安全;芯片内容只含数字化 mm:ss
28+
* (无注入面——写入的是我们自己的 HTML)。
29+
* @ai-context: 两种形态:章节锚点 `## 标题 [[⏱ 00:09]([[ts:9000]])]`(外带
30+
* `[...]` 包裹)先匹配,段落锚点 `[⏱ 00:00]([[ts:233]])` 兜底。
31+
*/
32+
export function renderTimestampAnchors(escaped: string): string {
33+
const chip = (_m: string, mm: string, ss: string): string =>
34+
`<span style="color:#0d9488;border-bottom:1px dashed #14b8a6;background:#f0fdfa;border-radius:3px;padding:0 4px" title="⏱ ${mm}:${ss} 跳转到会话对应片段">⏱ ${mm}:${ss}</span>`;
35+
return escaped
36+
// 章节形态(含包裹括号):`[[⏱ MM:SS]([[ts:ms]])]`
37+
.replace(/\[\[⏱ (\d+):(\d{2})\]\(\[\[ts:\d+\]\]\)\]/g, chip)
38+
// 段落形态:`[⏱ MM:SS]([[ts:ms]])`
39+
.replace(/\[⏱ (\d+):(\d{2})\]\(\[\[ts:\d+\]\]\)/g, chip);
40+
}

0 commit comments

Comments
 (0)