11'use client'
22
3- import { createContext , memo , useContext , useMemo , useRef } from 'react'
3+ import { createContext , memo , useCallback , useContext , useEffect , useMemo , useRef } from 'react'
4+ import { useRouter } from 'next/navigation'
45import type { Components , ExtraProps } from 'react-markdown'
56import ReactMarkdown from 'react-markdown'
7+ import rehypeSlug from 'rehype-slug'
68import remarkBreaks from 'remark-breaks'
79import remarkGfm from 'remark-gfm'
810import { Checkbox } from '@/components/emcn'
@@ -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,43 @@ 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
99+ id = { id }
100+ className = 'mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
101+ >
94102 { children }
95103 </ h1 >
96104 ) ,
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' >
105+ h2 : ( { id, children } : { id ?: string ; children ?: React . ReactNode } ) => (
106+ < h2
107+ id = { id }
108+ className = 'mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
109+ >
99110 { children }
100111 </ h2 >
101112 ) ,
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' >
113+ h3 : ( { id, children } : { id ?: string ; children ?: React . ReactNode } ) => (
114+ < h3
115+ id = { id }
116+ className = 'mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
117+ >
104118 { children }
105119 </ h3 >
106120 ) ,
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' >
121+ h4 : ( { id, children } : { id ?: string ; children ?: React . ReactNode } ) => (
122+ < h4
123+ id = { id }
124+ className = 'mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
125+ >
109126 { children }
110127 </ h4 >
111128 ) ,
@@ -138,16 +155,6 @@ const STATIC_MARKDOWN_COMPONENTS = {
138155 )
139156 } ,
140157 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- ) ,
151158 strong : ( { children } : { children ?: React . ReactNode } ) => (
152159 < strong className = 'break-words font-semibold text-[var(--text-primary)]' > { children } </ strong >
153160 ) ,
@@ -267,8 +274,75 @@ function InputRenderer({
267274 )
268275}
269276
277+ function isInternalHref (
278+ href : string ,
279+ origin = window . location . origin
280+ ) : { pathname : string ; hash : string } | null {
281+ if ( href . startsWith ( '#' ) ) return { pathname : '' , hash : href }
282+ try {
283+ const url = new URL ( href , origin )
284+ if ( url . origin === origin && url . pathname . startsWith ( '/workspace/' ) ) {
285+ return { pathname : url . pathname , hash : url . hash }
286+ }
287+ } catch {
288+ if ( href . startsWith ( '/workspace/' ) ) {
289+ const hashIdx = href . indexOf ( '#' )
290+ if ( hashIdx === - 1 ) return { pathname : href , hash : '' }
291+ return { pathname : href . slice ( 0 , hashIdx ) , hash : href . slice ( hashIdx ) }
292+ }
293+ }
294+ return null
295+ }
296+
297+ function AnchorRenderer ( { href, children } : { href ?: string ; children ?: React . ReactNode } ) {
298+ const navigate = useContext ( NavigateCtx )
299+ const parsed = useMemo ( ( ) => ( href ? isInternalHref ( href ) : null ) , [ href ] )
300+
301+ const handleClick = useCallback (
302+ ( e : React . MouseEvent < HTMLAnchorElement > ) => {
303+ if ( ! parsed || e . metaKey || e . ctrlKey || e . shiftKey || e . altKey ) return
304+
305+ e . preventDefault ( )
306+
307+ if ( parsed . pathname === '' && parsed . hash ) {
308+ const el = document . getElementById ( parsed . hash . slice ( 1 ) )
309+ if ( el ) {
310+ const container = el . closest ( '.overflow-auto' ) as HTMLElement | null
311+ if ( container ) {
312+ container . scrollTo ( { top : el . offsetTop - container . offsetTop , behavior : 'smooth' } )
313+ } else {
314+ el . scrollIntoView ( { behavior : 'smooth' } )
315+ }
316+ }
317+ return
318+ }
319+
320+ const destination = parsed . pathname + parsed . hash
321+ if ( navigate ) {
322+ navigate ( destination )
323+ } else {
324+ window . location . assign ( destination )
325+ }
326+ } ,
327+ [ parsed , navigate ]
328+ )
329+
330+ return (
331+ < a
332+ href = { href }
333+ target = { parsed ? undefined : '_blank' }
334+ rel = { parsed ? undefined : 'noopener noreferrer' }
335+ onClick = { handleClick }
336+ className = 'break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
337+ >
338+ { children }
339+ </ a >
340+ )
341+ }
342+
270343const MARKDOWN_COMPONENTS = {
271344 ...STATIC_MARKDOWN_COMPONENTS ,
345+ a : AnchorRenderer ,
272346 ul : UlRenderer ,
273347 ol : OlRenderer ,
274348 li : LiRenderer ,
@@ -284,6 +358,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
284358 isStreaming ?: boolean
285359 onCheckboxToggle ?: ( checkboxIndex : number , checked : boolean ) => void
286360} ) {
361+ const { push : navigate } = useRouter ( )
287362 const { ref : scrollRef } = useAutoScroll ( isStreaming )
288363 const { committed, incoming, generation } = useStreamingReveal ( content , isStreaming )
289364
@@ -295,10 +370,30 @@ const MarkdownPreview = memo(function MarkdownPreview({
295370 [ onCheckboxToggle ]
296371 )
297372
373+ const hasScrolledToHash = useRef ( false )
374+ useEffect ( ( ) => {
375+ const hash = window . location . hash
376+ if ( ! hash || hasScrolledToHash . current ) return
377+ const id = hash . slice ( 1 )
378+ const el = document . getElementById ( id )
379+ if ( ! el ) return
380+ hasScrolledToHash . current = true
381+ const container = el . closest ( '.overflow-auto' ) as HTMLElement | null
382+ if ( container ) {
383+ container . scrollTo ( { top : el . offsetTop - container . offsetTop , behavior : 'smooth' } )
384+ } else {
385+ el . scrollIntoView ( { behavior : 'smooth' } )
386+ }
387+ } , [ content ] )
388+
298389 const committedMarkdown = useMemo (
299390 ( ) =>
300391 committed ? (
301- < ReactMarkdown remarkPlugins = { REMARK_PLUGINS } components = { MARKDOWN_COMPONENTS } >
392+ < ReactMarkdown
393+ remarkPlugins = { REMARK_PLUGINS }
394+ rehypePlugins = { REHYPE_PLUGINS }
395+ components = { MARKDOWN_COMPONENTS }
396+ >
302397 { committed }
303398 </ ReactMarkdown >
304399 ) : null ,
@@ -307,30 +402,42 @@ const MarkdownPreview = memo(function MarkdownPreview({
307402
308403 if ( onCheckboxToggle ) {
309404 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 >
405+ < NavigateCtx . Provider value = { navigate } >
406+ < MarkdownCheckboxCtx . Provider value = { ctxValue } >
407+ < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
408+ < ReactMarkdown
409+ remarkPlugins = { REMARK_PLUGINS }
410+ rehypePlugins = { REHYPE_PLUGINS }
411+ components = { MARKDOWN_COMPONENTS }
412+ >
413+ { content }
414+ </ ReactMarkdown >
415+ </ div >
416+ </ MarkdownCheckboxCtx . Provider >
417+ </ NavigateCtx . Provider >
317418 )
318419 }
319420
320421 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 >
422+ < NavigateCtx . Provider value = { navigate } >
423+ < div ref = { scrollRef } className = 'h-full overflow-auto p-6' >
424+ { committedMarkdown }
425+ { incoming && (
426+ < div
427+ key = { generation }
428+ className = { cn ( isStreaming && 'animate-stream-fade-in' , '[&>:first-child]:mt-0' ) }
429+ >
430+ < ReactMarkdown
431+ remarkPlugins = { REMARK_PLUGINS }
432+ rehypePlugins = { REHYPE_PLUGINS }
433+ components = { MARKDOWN_COMPONENTS }
434+ >
435+ { incoming }
436+ </ ReactMarkdown >
437+ </ div >
438+ ) }
439+ </ div >
440+ </ NavigateCtx . Provider >
334441 )
335442} )
336443
0 commit comments