@@ -54,57 +54,62 @@ export default function MarkdownRenderer({
5454 const [ desktopTocCollapsed , setDesktopTocCollapsed ] = useState ( false ) ; // 桌面端目录是否折叠
5555 const [ readingProgress , setReadingProgress ] = useState ( 0 ) ; // 阅读进度
5656
57- // 简单的 ID 生成器 - 使用时间戳和随机数确保唯一性
58- const generateUniqueId = ( text : string ) => {
57+ // 生成稳定的唯一 ID - 基于内容哈希,确保服务端和客户端一致
58+ const generateStableUniqueId = ( text : string , index : number ) => {
5959 const baseId = text
6060 . toLowerCase ( )
6161 . replace ( / [ ^ \w \u4e00 - \u9fa5 \s - ] / g, "" )
6262 . replace ( / \s + / g, "-" ) ;
63- const uniqueSuffix =
64- Date . now ( ) . toString ( 36 ) + Math . random ( ) . toString ( 36 ) . substring ( 2 , 5 ) ;
65- return `${ baseId } -${ uniqueSuffix } ` ;
63+
64+ // 使用内容哈希 + 索引确保唯一性和稳定性
65+ const hash = text . split ( "" ) . reduce ( ( a , b ) => {
66+ a = ( a << 5 ) - a + b . charCodeAt ( 0 ) ;
67+ return a & a ;
68+ } , 0 ) ;
69+
70+ return `${ baseId } -${ Math . abs ( hash ) . toString ( 36 ) } -${ index } ` ;
6671 } ;
6772
68- // 使用 useMemo 优化性能,只在 content 变化时重新计算目录
69- const toc = useMemo ( ( ) => {
73+ // 预生成所有标题及其稳定 ID,确保目录和标题渲染使用一致的 ID
74+ const { toc, headingIdMap } = useMemo ( ( ) => {
7075 // 首先移除代码块内容,避免将代码中的#号误解析为标题
71- // 匹配 ``` 包裹的代码块(包括有语言标识的和无语言标识的)
7276 const codeBlockRegex = / ` ` ` [ \s \S ] * ?` ` ` / g;
7377 let contentWithoutCodeBlocks = safeContent . replace ( codeBlockRegex , "" ) ;
7478
75- // 同时移除行内代码,避免行内代码中的#号被解析为标题
76- // 匹配 ` 包裹的行内代码
7779 const inlineCodeRegex = / ` [ ^ ` ] * ` / g;
7880 contentWithoutCodeBlocks = contentWithoutCodeBlocks . replace (
7981 inlineCodeRegex ,
8082 ""
8183 ) ;
8284
83- // 正则表达式:匹配 Markdown 标题(# ## ### 等)
84- // ^(#{1,6}) 匹配行首的1-6个#号
85- // \s+ 匹配空格
86- // (.+)$ 匹配标题文本直到行尾
87- // gm 标志:g=全局匹配,m=多行模式
8885 const headingRegex = / ^ ( # { 1 , 6 } ) \s + ( .+ ) $ / gm;
8986 const headings : TocItem [ ] = [ ] ;
87+ const idMap = new Map < string , string > ( ) ;
88+ const textCounters = new Map < string , number > ( ) ; // 跟踪每个文本的出现次数
9089 let match ;
90+ let globalCounter = 0 ;
9191
9292 // 循环匹配所有标题
9393 while ( ( match = headingRegex . exec ( contentWithoutCodeBlocks ) ) !== null ) {
94- const level = match [ 1 ] . length ; // #号的数量就是标题级别
95- const text = match [ 2 ] . trim ( ) ; // 标题文本,去除首尾空格
94+ const level = match [ 1 ] . length ;
95+ const text = match [ 2 ] . trim ( ) ;
96+
97+ // 生成稳定的唯一 ID
98+ const id = generateStableUniqueId ( text , globalCounter ) ;
9699
97- // 生成 URL 友好的 ID(与目录生成逻辑保持一致)
98- const id = text
99- . toLowerCase ( )
100- . replace ( / [ ^ \w \u4e00 - \u9fa5 \s - ] / g, "" ) // 保留字母、数字、中文、空格、连字符
101- . replace ( / \s + / g, "-" ) ; // 空格转连字符
100+ // 为重复的文本创建唯一的键
101+ const currentCount = textCounters . get ( text ) || 0 ;
102+ const uniqueKey = currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
103+ textCounters . set ( text , currentCount + 1 ) ;
102104
103105 headings . push ( { id, text, level } ) ;
106+ idMap . set ( uniqueKey , id ) ; // 使用唯一键建立映射
107+
108+ globalCounter ++ ;
104109 }
105110
106- return headings ;
107- } , [ safeContent ] ) ; // 依赖数组:只有 content 变化时才重新计算
111+ return { toc : headings , headingIdMap : idMap } ;
112+ } , [ safeContent ] ) ;
108113
109114 // 使用 useEffect 监听滚动事件,实现目录高亮功能和阅读进度
110115 useEffect ( ( ) => {
@@ -144,6 +149,14 @@ export default function MarkdownRenderer({
144149 return ( ) => window . removeEventListener ( "scroll" , handleScroll ) ;
145150 } , [ ] ) ; // 空依赖数组:只在组件挂载和卸载时执行
146151
152+ // 重置标题计数器
153+ useEffect ( ( ) => {
154+ // 清理全局计数器
155+ if ( typeof window !== "undefined" ) {
156+ ( window as any ) . __headingCounters = { } ;
157+ }
158+ } , [ safeContent ] ) ; // 当内容变化时重置
159+
147160 // 跳转到指定章节的函数
148161 const scrollToHeading = ( id : string ) => {
149162 // 根据 ID 查找对应的 DOM 元素
@@ -246,8 +259,22 @@ export default function MarkdownRenderer({
246259 // 自定义 h1 标题渲染 - 为每个标题添加唯一 ID 以支持锚点跳转
247260 h1 : ( { children, ...props } ) => {
248261 const text = children ?. toString ( ) || "" ;
249- // 简单直接:每次都生成新的唯一 ID
250- const id = generateUniqueId ( text ) ;
262+ // 创建或获取当前标题的计数器
263+ const currentCount =
264+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
265+ const uniqueKey =
266+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
267+
268+ // 更新计数器
269+ if ( ! ( window as any ) . __headingCounters ) {
270+ ( window as any ) . __headingCounters = { } ;
271+ }
272+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
273+
274+ // 获取预生成的 ID
275+ const id =
276+ headingIdMap . get ( uniqueKey ) ||
277+ generateStableUniqueId ( text , currentCount ) ;
251278 return (
252279 < h1 id = { id } className = "scroll-mt-20" { ...props } >
253280 { children }
@@ -257,7 +284,19 @@ export default function MarkdownRenderer({
257284 // h2-h6 标题渲染
258285 h2 : ( { children, ...props } ) => {
259286 const text = children ?. toString ( ) || "" ;
260- const id = generateUniqueId ( text ) ;
287+ const currentCount =
288+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
289+ const uniqueKey =
290+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
291+
292+ if ( ! ( window as any ) . __headingCounters ) {
293+ ( window as any ) . __headingCounters = { } ;
294+ }
295+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
296+
297+ const id =
298+ headingIdMap . get ( uniqueKey ) ||
299+ generateStableUniqueId ( text , currentCount ) ;
261300 return (
262301 < h2 id = { id } className = "scroll-mt-20" { ...props } >
263302 { children }
@@ -266,7 +305,19 @@ export default function MarkdownRenderer({
266305 } ,
267306 h3 : ( { children, ...props } ) => {
268307 const text = children ?. toString ( ) || "" ;
269- const id = generateUniqueId ( text ) ;
308+ const currentCount =
309+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
310+ const uniqueKey =
311+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
312+
313+ if ( ! ( window as any ) . __headingCounters ) {
314+ ( window as any ) . __headingCounters = { } ;
315+ }
316+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
317+
318+ const id =
319+ headingIdMap . get ( uniqueKey ) ||
320+ generateStableUniqueId ( text , currentCount ) ;
270321 return (
271322 < h3 id = { id } className = "scroll-mt-20" { ...props } >
272323 { children }
@@ -275,7 +326,19 @@ export default function MarkdownRenderer({
275326 } ,
276327 h4 : ( { children, ...props } ) => {
277328 const text = children ?. toString ( ) || "" ;
278- const id = generateUniqueId ( text ) ;
329+ const currentCount =
330+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
331+ const uniqueKey =
332+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
333+
334+ if ( ! ( window as any ) . __headingCounters ) {
335+ ( window as any ) . __headingCounters = { } ;
336+ }
337+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
338+
339+ const id =
340+ headingIdMap . get ( uniqueKey ) ||
341+ generateStableUniqueId ( text , currentCount ) ;
279342 return (
280343 < h4 id = { id } className = "scroll-mt-20" { ...props } >
281344 { children }
@@ -284,7 +347,19 @@ export default function MarkdownRenderer({
284347 } ,
285348 h5 : ( { children, ...props } ) => {
286349 const text = children ?. toString ( ) || "" ;
287- const id = generateUniqueId ( text ) ;
350+ const currentCount =
351+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
352+ const uniqueKey =
353+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
354+
355+ if ( ! ( window as any ) . __headingCounters ) {
356+ ( window as any ) . __headingCounters = { } ;
357+ }
358+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
359+
360+ const id =
361+ headingIdMap . get ( uniqueKey ) ||
362+ generateStableUniqueId ( text , currentCount ) ;
288363 return (
289364 < h5 id = { id } className = "scroll-mt-20" { ...props } >
290365 { children }
@@ -293,7 +368,19 @@ export default function MarkdownRenderer({
293368 } ,
294369 h6 : ( { children, ...props } ) => {
295370 const text = children ?. toString ( ) || "" ;
296- const id = generateUniqueId ( text ) ;
371+ const currentCount =
372+ ( window as any ) . __headingCounters ?. [ text ] || 0 ;
373+ const uniqueKey =
374+ currentCount === 0 ? text : `${ text } ___${ currentCount } ` ;
375+
376+ if ( ! ( window as any ) . __headingCounters ) {
377+ ( window as any ) . __headingCounters = { } ;
378+ }
379+ ( window as any ) . __headingCounters [ text ] = currentCount + 1 ;
380+
381+ const id =
382+ headingIdMap . get ( uniqueKey ) ||
383+ generateStableUniqueId ( text , currentCount ) ;
297384 return (
298385 < h6 id = { id } className = "scroll-mt-20" { ...props } >
299386 { children }
0 commit comments