119 lines
4 KiB
TypeScript
119 lines
4 KiB
TypeScript
import { memo, useMemo, type MouseEvent, type ReactNode } from 'react'
|
|
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import rehypeHighlight from 'rehype-highlight'
|
|
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
|
|
import { supportsModernRegexFeatures } from '../utils/regexCapabilities'
|
|
import { openExternalUrl } from '../utils/url'
|
|
|
|
const MODERN_REGEX_AVAILABLE = supportsModernRegexFeatures()
|
|
const REMARK_PLUGINS = MODERN_REGEX_AVAILABLE ? [remarkGfm] : []
|
|
const REHYPE_PLUGINS = MODERN_REGEX_AVAILABLE ? [rehypeHighlight] : []
|
|
|
|
function wikilinkUrlTransform(url: string): string {
|
|
if (url.startsWith(WIKILINK_SCHEME)) return url
|
|
return defaultUrlTransform(url)
|
|
}
|
|
|
|
function isExplicitWebUrl(href?: string): href is string {
|
|
const lowerHref = href?.trim().toLowerCase() ?? ''
|
|
return lowerHref.startsWith('http://') || lowerHref.startsWith('https://')
|
|
}
|
|
|
|
function openExplicitWebUrl(event: MouseEvent<HTMLAnchorElement>, href: string) {
|
|
event.preventDefault()
|
|
void openExternalUrl(href).catch((error) => {
|
|
console.warn('[ai] Failed to open external link:', error)
|
|
})
|
|
}
|
|
|
|
function localNoteTarget(href?: string): string | null {
|
|
const raw = href?.trim()
|
|
if (!raw || raw.startsWith('#') || raw.startsWith('/') || raw.startsWith('\\')) return null
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return null
|
|
try {
|
|
return decodeURIComponent(raw)
|
|
.split(/[?#]/, 1)[0]
|
|
.replace(/^\.\//, '')
|
|
.replace(/\.(?:md|hdlp)$/i, '') || null
|
|
} catch {
|
|
return raw.replace(/^\.\//, '').replace(/\.(?:md|hdlp)$/i, '') || null
|
|
}
|
|
}
|
|
|
|
interface MarkdownContentProps {
|
|
content: string
|
|
onWikilinkClick?: (target: string) => void
|
|
}
|
|
|
|
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
|
|
const processedContent = useMemo(
|
|
() => onWikilinkClick ? preprocessWikilinks(content) : content,
|
|
[content, onWikilinkClick],
|
|
)
|
|
|
|
const components = useMemo(() => {
|
|
return {
|
|
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
|
if (onWikilinkClick && href?.startsWith(WIKILINK_SCHEME)) {
|
|
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
|
|
return (
|
|
<a
|
|
ref={(node) => {
|
|
node?.setAttribute('role', 'link')
|
|
node?.setAttribute('tabindex', '0')
|
|
}}
|
|
href={href}
|
|
className="chat-wikilink border-0 bg-transparent p-0"
|
|
data-wikilink-target={target}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
onWikilinkClick(target)
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
)
|
|
}
|
|
if (isExplicitWebUrl(href)) {
|
|
return <a href={href} onClick={(event) => openExplicitWebUrl(event, href)}>{children}</a>
|
|
}
|
|
const noteTarget = onWikilinkClick ? localNoteTarget(href) : null
|
|
if (noteTarget) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
className="chat-wikilink border-0 bg-transparent p-0"
|
|
data-wikilink-target={noteTarget}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
onWikilinkClick?.(noteTarget)
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
)
|
|
}
|
|
return <a href={href}>{children}</a>
|
|
},
|
|
table: ({ children }: { children?: ReactNode }) => (
|
|
<div className="ai-markdown-table-wrap" role="region" aria-label="表格,可横向滚动">
|
|
<table>{children}</table>
|
|
</div>
|
|
),
|
|
}
|
|
}, [onWikilinkClick])
|
|
|
|
return (
|
|
<div className="ai-markdown min-w-0 max-w-full overflow-hidden">
|
|
<Markdown
|
|
remarkPlugins={REMARK_PLUGINS}
|
|
rehypePlugins={REHYPE_PLUGINS}
|
|
components={components}
|
|
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
|
|
>
|
|
{processedContent}
|
|
</Markdown>
|
|
</div>
|
|
)
|
|
})
|