Skip to content

Commit 16d73ba

Browse files
author
Theodore Li
committed
fix(ui): handle markdown internal links
1 parent ace8779 commit 16d73ba

File tree

1 file changed

+94
-40
lines changed

1 file changed

+94
-40
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 94 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client'
22

3-
import { createContext, memo, useContext, useMemo, useRef } from 'react'
3+
import { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react'
44
import type { Components, ExtraProps } from 'react-markdown'
55
import ReactMarkdown from 'react-markdown'
66
import remarkBreaks from 'remark-breaks'
7+
import rehypeSlug from 'rehype-slug'
78
import remarkGfm from 'remark-gfm'
9+
import { useRouter } from 'next/navigation'
810
import { Checkbox } from '@/components/emcn'
911
import { cn } from '@/lib/core/utils/cn'
1012
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({
7072
})
7173

7274
const 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. */
8487
const CheckboxIndexCtx = createContext(-1)
8588

89+
const NavigateCtx = createContext<((path: string) => void) | null>(null)
90+
8691
const 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+
270318
const 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

Comments
 (0)