11'use client'
22
3- import { createContext , memo , useContext , useMemo , useRef } from 'react'
3+ import { createContext , memo , useCallback , useContext , useMemo , useRef } from 'react'
44import type { Components , ExtraProps } from 'react-markdown'
55import ReactMarkdown from 'react-markdown'
66import remarkBreaks from 'remark-breaks'
7+ import rehypeSlug from 'rehype-slug'
78import remarkGfm from 'remark-gfm'
9+ import { useRouter } from 'next/navigation'
810import { Checkbox } from '@/components/emcn'
911import { cn } from '@/lib/core/utils/cn'
1012import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({
7072} )
7173
7274const REMARK_PLUGINS = [ remarkGfm , remarkBreaks ]
75+ const REHYPE_PLUGINS = [ rehypeSlug ]
7376
7477/**
7578 * Carries the contentRef and toggle handler from MarkdownPreview down to the
@@ -83,29 +86,31 @@ const MarkdownCheckboxCtx = createContext<{
8386/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
8487const CheckboxIndexCtx = createContext ( - 1 )
8588
89+ const NavigateCtx = createContext < ( ( path : string ) => void ) | null > ( null )
90+
8691const STATIC_MARKDOWN_COMPONENTS = {
8792 p : ( { children } : { children ?: React . ReactNode } ) => (
8893 < p className = 'mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0' >
8994 { children }
9095 </ p >
9196 ) ,
92- h1 : ( { children } : { children ?: React . ReactNode } ) => (
93- < h1 className = 'mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0' >
97+ h1 : ( { id , children } : { id ?: string ; children ?: React . ReactNode } ) => (
98+ < h1 id = { id } className = 'mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0' >
9499 { children }
95100 </ h1 >
96101 ) ,
97- h2 : ( { children } : { children ?: React . ReactNode } ) => (
98- < h2 className = 'mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0' >
102+ h2 : ( { id , children } : { id ?: string ; children ?: React . ReactNode } ) => (
103+ < h2 id = { id } className = 'mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0' >
99104 { children }
100105 </ h2 >
101106 ) ,
102- h3 : ( { children } : { children ?: React . ReactNode } ) => (
103- < h3 className = 'mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0' >
107+ h3 : ( { id , children } : { id ?: string ; children ?: React . ReactNode } ) => (
108+ < h3 id = { id } className = 'mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0' >
104109 { children }
105110 </ h3 >
106111 ) ,
107- h4 : ( { children } : { children ?: React . ReactNode } ) => (
108- < h4 className = 'mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0' >
112+ h4 : ( { id , children } : { id ?: string ; children ?: React . ReactNode } ) => (
113+ < h4 id = { id } className = 'mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0' >
109114 { children }
110115 </ h4 >
111116 ) ,
@@ -138,16 +143,6 @@ const STATIC_MARKDOWN_COMPONENTS = {
138143 )
139144 } ,
140145 pre : ( { children } : { children ?: React . ReactNode } ) => < > { children } </ > ,
141- a : ( { href, children } : { href ?: string ; children ?: React . ReactNode } ) => (
142- < a
143- href = { href }
144- target = '_blank'
145- rel = 'noopener noreferrer'
146- className = 'break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
147- >
148- { children }
149- </ a >
150- ) ,
151146 strong : ( { children } : { children ?: React . ReactNode } ) => (
152147 < strong className = 'break-words font-semibold text-[var(--text-primary)]' > { children } </ strong >
153148 ) ,
@@ -267,8 +262,62 @@ function InputRenderer({
267262 )
268263}
269264
265+ function isInternalHref ( href : string ) : { pathname : string ; hash : string } | null {
266+ if ( href . startsWith ( '#' ) ) return { pathname : '' , hash : href }
267+ try {
268+ const url = new URL ( href , window . location . origin )
269+ if ( url . origin === window . location . origin ) {
270+ return { pathname : url . pathname , hash : url . hash }
271+ }
272+ } catch {
273+ if ( href . startsWith ( '/' ) ) {
274+ const hashIdx = href . indexOf ( '#' )
275+ if ( hashIdx === - 1 ) return { pathname : href , hash : '' }
276+ return { pathname : href . slice ( 0 , hashIdx ) , hash : href . slice ( hashIdx ) }
277+ }
278+ }
279+ return null
280+ }
281+
282+ function AnchorRenderer ( { href, children } : { href ?: string ; children ?: React . ReactNode } ) {
283+ const navigate = useContext ( NavigateCtx )
284+ const parsed = useMemo ( ( ) => ( href ? isInternalHref ( href ) : null ) , [ href ] )
285+
286+ const handleClick = useCallback (
287+ ( e : React . MouseEvent < HTMLAnchorElement > ) => {
288+ if ( ! parsed || e . metaKey || e . ctrlKey || e . shiftKey || e . altKey ) return
289+
290+ e . preventDefault ( )
291+
292+ if ( parsed . pathname === '' && parsed . hash ) {
293+ const el = document . getElementById ( parsed . hash . slice ( 1 ) )
294+ el ?. scrollIntoView ( { behavior : 'smooth' } )
295+ return
296+ }
297+
298+ if ( navigate ) {
299+ navigate ( parsed . pathname + parsed . hash )
300+ }
301+ } ,
302+ [ parsed , navigate ]
303+ )
304+
305+ return (
306+ < a
307+ href = { href }
308+ target = { parsed ? undefined : '_blank' }
309+ rel = { parsed ? undefined : 'noopener noreferrer' }
310+ onClick = { handleClick }
311+ className = 'break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
312+ >
313+ { children }
314+ </ a >
315+ )
316+ }
317+
270318const MARKDOWN_COMPONENTS = {
271319 ...STATIC_MARKDOWN_COMPONENTS ,
320+ a : AnchorRenderer ,
272321 ul : UlRenderer ,
273322 ol : OlRenderer ,
274323 li : LiRenderer ,
@@ -284,6 +333,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
284333 isStreaming ?: boolean
285334 onCheckboxToggle ?: ( checkboxIndex : number , checked : boolean ) => void
286335} ) {
336+ const { push : navigate } = useRouter ( )
287337 const { ref : scrollRef } = useAutoScroll ( isStreaming )
288338 const { committed, incoming, generation } = useStreamingReveal ( content , isStreaming )
289339
@@ -298,7 +348,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
298348 const committedMarkdown = useMemo (
299349 ( ) =>
300350 committed ? (
301- < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { MARKDOWN_COMPONENTS } >
351+ < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } rehypePlugins = { REHYPE_PLUGINS } components = { MARKDOWN_COMPONENTS } >
302352 { committed }
303353 </ ReactMarkdown >
304354 ) : null ,
@@ -307,30 +357,34 @@ const MarkdownPreview = memo(function MarkdownPreview({
307357
308358 if ( onCheckboxToggle ) {
309359 return (
310- < MarkdownCheckboxCtx . Provider value = { ctxValue } >
311- < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
312- < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { MARKDOWN_COMPONENTS } >
313- { content }
314- </ ReactMarkdown >
315- </ div >
316- </ MarkdownCheckboxCtx . Provider >
360+ < NavigateCtx . Provider value = { navigate } >
361+ < MarkdownCheckboxCtx . Provider value = { ctxValue } >
362+ < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
363+ < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } rehypePlugins = { REHYPE_PLUGINS } components = { MARKDOWN_COMPONENTS } >
364+ { content }
365+ </ ReactMarkdown >
366+ </ div >
367+ </ MarkdownCheckboxCtx . Provider >
368+ </ NavigateCtx . Provider >
317369 )
318370 }
319371
320372 return (
321- < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
322- { committedMarkdown }
323- { incoming && (
324- < div
325- key = { generation }
326- className = { cn ( isStreaming && 'animate-stream-fade-in' , '[&>:first-child]:mt-0' ) }
327- >
328- < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { MARKDOWN_COMPONENTS } >
329- { incoming }
330- </ ReactMarkdown >
331- </ div >
332- ) }
333- </ div >
373+ < NavigateCtx . Provider value = { navigate } >
374+ < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
375+ { committedMarkdown }
376+ { incoming && (
377+ < div
378+ key = { generation }
379+ className = { cn ( isStreaming && 'animate-stream-fade-in' , '[&>:first-child]:mt-0' ) }
380+ >
381+ < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } rehypePlugins = { REHYPE_PLUGINS } components = { MARKDOWN_COMPONENTS } >
382+ { incoming }
383+ </ ReactMarkdown >
384+ </ div >
385+ ) }
386+ </ div >
387+ </ NavigateCtx . Provider >
334388 )
335389} )
336390
0 commit comments