Skip to content

Commit 2c3aa60

Browse files
committed
refactor(notes): 拆出 markdown 自定义组件映射
1 parent e4ceffb commit 2c3aa60

3 files changed

Lines changed: 305 additions & 214 deletions

File tree

‎app/src/components/NoteMarkdown.tsx‎

Lines changed: 21 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,26 @@
11
/**
22
* NoteMarkdown — 笔记阅读视图 Markdown 渲染(H5 自 NotesPage 拆分)。
33
*
4+
* @ai-context: **react-markdown 站点 #1**(全站运行时站点恰 2,另一个是 `ChatMessageMarkdown`;
5+
* 判据 `views/note/noteViews.test.tsx:281-286`)。批 7 T13 拆件后本件只留
6+
* 「四条 remark 插件 + rehype-katex + URL 消毒 + 组件映射装配」这四件事,
7+
* 自定义渲染件与它们的闭包状态**整段**搬进 `noteMarkdownComponents.tsx`(工厂形态)——
8+
* 拆件的唯一目的是给 T14 的「#2 并入 #1」腾出净增头寸(本件拆前只剩 5 行余量)。
49
* @ai-context: 集中全部自定义渲染:任务清单勾选回写(H1)、标题锚点(M5)、
510
* 搜索高亮(M6)、时间戳回链(L6 radix)、图片/代码/表格样式。
6-
* @ai-context: H1 修复——勾选 checkbox 只改目标行:渲染时按出现顺序计数,
7-
* 第 n 个 checkbox 对应源文本第 n 个任务行(taskLineIndices)。
8-
* 原实现 lines.map 匹配全部同态行 → 勾选任一即全翻转(数据损坏);
9-
* 且持久化 createVersion: true 保留可回滚快照(由父组件执行)。
10-
* @ai-context: M6 修复——搜索高亮纯数据驱动:渲染层按关键词 split 文本输出
11-
* <mark> 片段(同 SessionListPanel 模式),替代 TreeWalker 直改
12-
* React 托管 DOM(surroundContents 与虚拟 DOM 协调冲突)。
13-
* @ai-context: M5 修复——标题行号索引渲染前 useMemo 一次预计算(Map + 出现
14-
* 序号消歧同名标题),替代每个标题 O(n) findIndex + as string 断言。
11+
* —— 逐条实现与修复背景见 `noteMarkdownComponents.tsx` 的同名 `@ai-context`。
1512
* @ai-context: 批 6 T26——`[[ts:ms]]` 的 ms 不再只进 title:新增**可选** `onOpenSessionAt` 把它逐字
1613
* 传出(缺省时与今天逐字相同;title 一字未改)。前置缺陷见 `noteUrlTransform`:
1714
* `react-markdown@10.1.0` 的默认 URL 消毒把它清成空串 ⇒ 回链芯片此前从未渲染。
1815
*/
19-
import { isValidElement, cloneElement, useMemo, useRef } from "react";
20-
import type { ReactElement, ReactNode } from "react";
2116
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
2217
import remarkBreaks from "remark-breaks";
2318
import remarkGfm from "remark-gfm";
2419
import remarkMath from "remark-math";
2520
import rehypeKatex from "rehype-katex";
2621
import type { Note } from "../types";
2722
import { remarkMarkHighlight } from "../utils/remarkMarkHighlight";
28-
import NoteImage from "./NoteImage";
23+
import { noteMarkdownComponents } from "./noteMarkdownComponents";
2924

3025
/**
3126
* `remarkPlugins` 槽的元素类型(= `react-markdown` 的 `Options["remarkPlugins"]` 的元素)。
@@ -39,6 +34,16 @@ import NoteImage from "./NoteImage";
3934
*/
4035
export type RemarkPlugin = NonNullable<Parameters<typeof ReactMarkdown>[0]["remarkPlugins"]>[number];
4136

37+
/**
38+
* `components` 槽的类型(= `react-markdown` 的 `Components`)—— 与上一条**同手法**(取自已有的默认导入,
39+
* 不新增一行模块说明符)。批 7 T13 拆件后由 `noteMarkdownComponents.tsx` 经 **type-only import** 取用。
40+
*
41+
* @ai-context 为什么类型要从主件「反向」取:那个新件里**不许**出现 `react-markdown` 说明符(同上,
42+
* 否则运行时站点读数变 3)。`import type` 是编译期擦除的 ⇒ **无运行时循环依赖**,
43+
* 先例 `components/NoteListToolbar.tsx:16` 的 `import type { SortMode } from "./NoteListView"`。
44+
*/
45+
export type MarkdownComponents = NonNullable<Parameters<typeof ReactMarkdown>[0]["components"]>;
46+
4247
interface Props {
4348
note: Note;
4449
/** 搜索关键词(空串=不高亮)——由 NoteReadingView 按 searchActive 门控传入 */
@@ -70,132 +75,15 @@ interface Props {
7075
* 而 `react-markdown@10.1.0` 的 `defaultUrlTransform` 把无协议 URL 一律清空 ⇒ 回链芯片此前
7176
* **根本没渲染**(落成 `<a href="">⏱ 00:52</a>`,下面的 `tsMatch` 永不命中;比 R5.5 顺带④ 记的还坏)。
7277
* 安全面零放松:只放行这一种形态(解码回规范形),其余全交回默认消毒器;该 href 仅用于识别,最终渲染成 `<span>`。
78+
* ⇄ 规范形的**匹配**在 `noteMarkdownComponents.tsx` 的 `TS_HREF_RE`(`a` 渲染件用)。
7379
*/
74-
const TS_HREF_RE = /^\[\[ts:(\d+)\]\]$/;
7580
const TS_HREF_ENCODED_RE = /^%5B%5Bts:(\d+)%5D%5D$/i;
7681
const noteUrlTransform = (url: string): string => {
7782
const m = TS_HREF_ENCODED_RE.exec(url);
7883
return m ? `[[ts:${m[1]}]]` : defaultUrlTransform(url);
7984
};
8085

81-
/** 递归展平 React 子节点为纯文本(ReactNode 收窄——替代 as string 断言) */
82-
function flattenText(node: ReactNode): string {
83-
if (node == null || typeof node === "boolean") return "";
84-
if (typeof node === "string" || typeof node === "number") return String(node);
85-
if (Array.isArray(node)) return node.map(flattenText).join("");
86-
if (isValidElement(node)) {
87-
const children = (node.props as { children?: ReactNode }).children;
88-
return flattenText(children);
89-
}
90-
return "";
91-
}
92-
93-
/**
94-
* 数据驱动搜索高亮:递归遍历 children,字符串叶子按关键词(大小写不敏感)
95-
* split 并插入 <mark> 片段;元素节点 clone 后继续下钻。不触碰真实 DOM。
96-
*/
97-
function highlightNode(node: ReactNode, query: string): ReactNode {
98-
if (!query) return node;
99-
const lower = query.toLowerCase();
100-
101-
const walk = (n: ReactNode, keyPrefix: string): ReactNode => {
102-
if (typeof n === "string") {
103-
const parts: ReactNode[] = [];
104-
let rest = n;
105-
let seq = 0;
106-
let idx = rest.toLowerCase().indexOf(lower);
107-
while (idx >= 0) {
108-
if (idx > 0) parts.push(rest.slice(0, idx));
109-
parts.push(
110-
<mark
111-
key={`${keyPrefix}-h${seq++}`}
112-
data-note-search-hit=""
113-
style={{ background: "#fde68a", borderRadius: 2, padding: "0 1px" }}
114-
>
115-
{rest.slice(idx, idx + query.length)}
116-
</mark>,
117-
);
118-
rest = rest.slice(idx + query.length);
119-
idx = rest.toLowerCase().indexOf(lower);
120-
}
121-
if (rest) parts.push(rest);
122-
// 边界修复:文本整体等于关键词时 parts 仅含 1 个 <mark>(length===1),
123-
// 原 `length > 1 ? <>...</> : n` 会退回原始未高亮字符串(高亮丢失+计数漏计)
124-
if (parts.length === 0) return n;
125-
return parts.length === 1 ? parts[0] : <>{parts}</>;
126-
}
127-
if (Array.isArray(n)) return n.map((c, i) => walk(c, `${keyPrefix}-${i}`));
128-
if (isValidElement(n)) {
129-
// 收窄为带 children 的元素类型(ReactMarkdown 产出的节点均含 children)
130-
const el = n as ReactElement<{ children?: ReactNode }>;
131-
return cloneElement(el, { children: walk(el.props.children, keyPrefix) });
132-
}
133-
return n;
134-
};
135-
136-
return walk(node, "hl");
137-
}
138-
13986
export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSession, onOpenSessionAt, onImageOpen, remarkPluginsExtra }: Props) {
140-
// H1:任务行索引(源文本中每个任务行的行号,按出现顺序)
141-
// 正则与 remark-gfm 清单语法对齐:-/*/+ 无序 + 有序列表(\d{1,9}[.)]),
142-
// 勾选框大小写均认([x]/[X])——否则渲染序号与索引数组错位会写错行
143-
const taskLineRegex = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\[[ xX]\]/;
144-
const taskLineIndices = useMemo(() => {
145-
const idxs: number[] = [];
146-
note.content.split("\n").forEach((line, i) => {
147-
if (taskLineRegex.test(line)) idxs.push(i);
148-
});
149-
return idxs;
150-
}, [note.content]);
151-
152-
// M5:标题文本→行号索引一次预计算(key=`级别:文本`,同名标题按出现序消歧)
153-
const headingIndexMap = useMemo(() => {
154-
const map = new Map<string, number[]>();
155-
note.content.split("\n").forEach((line, i) => {
156-
const m = line.match(/^(#{1,6})\s+(.+)/);
157-
if (m) {
158-
const key = `${m[1].length}:${m[2].trim()}`;
159-
const arr = map.get(key);
160-
if (arr) arr.push(i);
161-
else map.set(key, [i]);
162-
}
163-
});
164-
return map;
165-
}, [note.content]);
166-
167-
// 渲染期计数器(每次渲染先清零——react-markdown 按源顺序同步渲染子节点)
168-
const taskCounterRef = useRef(0);
169-
const headingOccRef = useRef(new Map<string, number>());
170-
taskCounterRef.current = 0;
171-
headingOccRef.current.clear();
172-
173-
/** 标题锚点 id(M5:查预计算索引 + 出现序号消歧) */
174-
const headingId = (level: number, children: ReactNode): string | undefined => {
175-
const key = `${level}:${flattenText(children).trim()}`;
176-
const occ = headingOccRef.current.get(key) ?? 0;
177-
headingOccRef.current.set(key, occ + 1);
178-
const idx = headingIndexMap.get(key)?.[occ];
179-
return idx != null ? `heading-${idx}` : undefined;
180-
};
181-
182-
/** checkbox 勾选回写(H1:仅替换目标行索引) */
183-
const handleTaskChange = (order: number, checked: boolean) => {
184-
const targetIdx = taskLineIndices[order];
185-
if (targetIdx == null) return;
186-
const lines = note.content.split("\n");
187-
const line = lines[targetIdx];
188-
// 防御:该行若已不是任务行(渲染与内容不同步),放弃而非误改
189-
if (!taskLineRegex.test(line)) return;
190-
// 字符级替换:只动勾选框本身,兼容 [x]/[X] 与任意清单标记(-/*+/有序)
191-
lines[targetIdx] = checked
192-
? line.replace(/\[[ xX]\]/, "[x]")
193-
: line.replace(/\[[xX]\]/, "[ ]");
194-
onTaskToggle(lines.join("\n"));
195-
};
196-
197-
const hl = (children: ReactNode): ReactNode => highlightNode(children, searchQuery);
198-
19987
return (
20088
<ReactMarkdown
20189
// v0.15:remark-breaks——单换行(软换行)渲染为 <br>,与编辑态所见一致
@@ -206,88 +94,9 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
20694
// 批 6 T26:放行内部 `[[ts:ms]]` 锚点(默认消毒器会清空它 ⇒ 回链芯片曾整条不可达;
20795
// 见 `noteUrlTransform`)。**不是**放松消毒:其余 URL 仍走 `defaultUrlTransform`。
20896
urlTransform={noteUrlTransform}
209-
components={{
210-
// v0.14 B:==文本== 荧光笔(remark 插件产出 mdast mark 节点 + hName/hProperties)
211-
// v0.16.1:多色——className 由插件注入(note-mark[-{colorId}],样式见 note-mark.css);
212-
// 原实现硬编码单色黄(且无 hName 经 defaultUnknownHandler 落 div——
213-
// 组件按 hast tagName 匹配实际从未命中,现由插件 hName 修复)
214-
mark: ({ node, className, children, ...props }) => (
215-
<mark className={className} {...props}>{hl(children)}</mark>
216-
),
217-
// 任务清单勾选(H1:渲染时记录序号 → 仅替换对应源行)
218-
input: ({ node, ...props }) => {
219-
const order = taskCounterRef.current++;
220-
return <input {...props} onChange={(e) => handleTaskChange(order, e.target.checked)} />;
221-
},
222-
// 标题锚点供大纲跳转(M5:预计算索引,无 as string 断言)
223-
h1: ({ node, children, ...props }) => (
224-
<h1 id={headingId(1, children)} {...props} style={{ fontSize: 20, margin: "16px 0 8px", borderBottom: "1px solid #e5e7eb", paddingBottom: 4 }}>{hl(children)}</h1>
225-
),
226-
h2: ({ node, children, ...props }) => (
227-
<h2 id={headingId(2, children)} {...props} style={{ fontSize: 17, margin: "14px 0 6px" }}>{hl(children)}</h2>
228-
),
229-
h3: ({ node, children, ...props }) => (
230-
<h3 id={headingId(3, children)} {...props} style={{ fontSize: 15, margin: "12px 0 4px", color: "#374151" }}>{hl(children)}</h3>
231-
),
232-
h4: ({ node, children, ...props }) => <h4 id={headingId(4, children)} {...props}>{hl(children)}</h4>,
233-
h5: ({ node, children, ...props }) => <h5 id={headingId(5, children)} {...props}>{hl(children)}</h5>,
234-
h6: ({ node, children, ...props }) => <h6 id={headingId(6, children)} {...props}>{hl(children)}</h6>,
235-
p: ({ node, children, ...props }) => <p {...props}>{hl(children)}</p>,
236-
li: ({ node, children, ...props }) => <li {...props}>{hl(children)}</li>,
237-
// 时间戳回链渲染(P1/A5 预览预备;L6:parseInt 补 radix 10)
238-
a: ({ node, href, children, ...props }) => {
239-
const tsMatch = href?.match(TS_HREF_RE);
240-
if (tsMatch) {
241-
const ms = parseInt(tsMatch[1], 10);
242-
const sec = Math.floor(ms / 1000);
243-
const min = Math.floor(sec / 60);
244-
const secStr = String(sec % 60).padStart(2, "0");
245-
return (
246-
<span
247-
style={{ cursor: "pointer", color: "#0d9488", borderBottom: "1px dashed #14b8a6", background: "#f0fdfa", borderRadius: 3, padding: "0 4px" }}
248-
onClick={() => {
249-
if (!note.session_id) return;
250-
// T26:带 ms 的分支优先(ms 逐字传出,不截断);缺省退回既有单参回调
251-
if (onOpenSessionAt) onOpenSessionAt(note.session_id, ms);
252-
else onOpenSession?.(note.session_id);
253-
}}
254-
title={`⏱ 跳转到会话 ${Math.floor(ms / 60000)}:${secStr} 处 —— 点击查看视频对应片段`}
255-
>
256-
⏱ {min}:{secStr}
257-
</span>
258-
);
259-
}
260-
return <a href={href} {...props} style={{ color: "#2563eb" }}>{hl(children)}</a>;
261-
},
262-
// 代码块(inline 高亮走字符串叶子 split)
263-
code: ({ node, className, children, ...props }) => {
264-
const isInline = !className;
265-
if (isInline) {
266-
return <code style={{ background: "#f3f4f6", padding: "1px 4px", borderRadius: 3, fontSize: 13 }} {...props}>{hl(children)}</code>;
267-
}
268-
return (
269-
<pre style={{ background: "#1f2937", color: "#e5e7eb", borderRadius: 6, padding: 12, overflowX: "auto", fontSize: 13 }}>
270-
<code className={className} {...props}>{children}</code>
271-
</pre>
272-
);
273-
},
274-
// 表格
275-
table: ({ node, ...props }) => (
276-
<div style={{ overflowX: "auto" }}>
277-
<table style={{ borderCollapse: "collapse", width: "100%", fontSize: 13 }} {...props} />
278-
</div>
279-
),
280-
th: ({ node, children, ...props }) => <th style={{ border: "1px solid #d1d5db", padding: "6px 10px", background: "#f9fafb", fontWeight: 600 }} {...props}>{hl(children)}</th>,
281-
td: ({ node, children, ...props }) => <td style={{ border: "1px solid #d1d5db", padding: "6px 10px" }} {...props}>{hl(children)}</td>,
282-
// 图片(v0.10.1:本地相对引用经 resolve+convertFileSrc,点击放大;外部 URL 直出)
283-
img: ({ src, alt }) => (
284-
<NoteImage src={src ?? ""} alt={alt ?? ""} noteId={note.id} onOpen={(url, title) => onImageOpen(url, title)} />
285-
),
286-
// 引用
287-
blockquote: ({ node, children, ...props }) => (
288-
<blockquote style={{ borderLeft: "3px solid #0d9488", margin: "8px 0", padding: "4px 12px", color: "#6b7280", background: "#f9fafb" }} {...props}>{hl(children)}</blockquote>
289-
),
290-
}}
97+
// 批 7 T13:自定义渲染件整段搬到 `noteMarkdownComponents.tsx`(工厂吃同一组上下文;
98+
// 工厂在渲染体内无条件调用恰一次 ⇒ 其内部 hooks 与搬迁前同序)
99+
components={noteMarkdownComponents({ note, searchQuery, onTaskToggle, onOpenSession, onOpenSessionAt, onImageOpen })}
291100
>
292101
{note.content}
293102
</ReactMarkdown>

0 commit comments

Comments
 (0)