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" ;
2116import ReactMarkdown , { defaultUrlTransform } from "react-markdown" ;
2217import remarkBreaks from "remark-breaks" ;
2318import remarkGfm from "remark-gfm" ;
2419import remarkMath from "remark-math" ;
2520import rehypeKatex from "rehype-katex" ;
2621import type { Note } from "../types" ;
2722import { 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 */
4035export 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+
4247interface 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 = / ^ \[ \[ t s : ( \d + ) \] \] $ / ;
7580const TS_HREF_ENCODED_RE = / ^ % 5 B % 5 B t s : ( \d + ) % 5 D % 5 D $ / i;
7681const 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-
13986export 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 + \[ [ x X ] \] / ;
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 ( / \[ [ x X ] \] / , "[x]" )
193- : line . replace ( / \[ [ x X ] \] / , "[ ]" ) ;
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