Skip to content
Clone

fix(web): route rich-text links through the router

Links inside rendered markdown and inside embed HTML were plain anchors,
so clicking one inside the app did a full page load and lost the shell,
and a same-tab jump to an external site was indistinguishable from an
internal one.

Handle the click once: same-origin app paths navigate through
react-router, external and API links open in a new tab, and modified
clicks keep their browser behaviour.
ZhenYi committed
parent 2687ace commit 2f11d69 Browse files

4 files changed

+51 −0

File tree 4
Modified web/src/features/chat/ui/parts.tsx View web/src/features/chat/ui/parts.tsx
@@ -26,6 +26,7 @@
2626 type UserInfo,
2727} from '@/entities/chat'
2828import { useT } from '@/shared/i18n'
29import { useSpaLinkClick } from '@/shared/lib'
2930
3031const avatarPalette = ['#b77945', '#448675', '#b6638d', '#6871c9', '#b96369', '#4f8a90', '#9272bb']
3132
@@ -431,12 +432,15 @@
431432/** 平台 embed:直接展示服务端生成的 HTML;普通链接退化为 OpenGraph 卡片。 */
432433export function EmbedView({ embed }: { embed: MessageEmbed }) {
433434 const html = useMemo(() => (embed.html ? sanitizeEmbed(embed.html) : ''), [embed.html])
435 // embed HTML 里的链接同样不允许直接跳转:站内走 react-router,站外开新标签页。
436 const onClick = useSpaLinkClick()
434437 if (html) {
435438 return (
436439 <span
437440 className="chat-embed my-1.5 block min-w-0 max-w-full select-text"
438441 data-embed-platform={embed.platform ?? ''}
439442 data-embed-kind={embed.kind ?? ''}
443 onClick={onClick}
440444 dangerouslySetInnerHTML={{ __html: html }}
441445 />
442446 )
Modified web/src/shared/lib/index.ts View web/src/shared/lib/index.ts
@@ -35,6 +35,7 @@
3535export { getRegion, getMinAge } from './config'
3636export { useAsync, type AsyncCacheOptions, type AsyncState } from './use-async'
3737export { prefetch, runShared, PREFETCH_FRESH_MS, type PrefetchOptions } from './prefetch'
38export { useSpaLinkClick } from './spa-links'
3839export * from './cache-keys'
3940export { formatBytes, shortSha, fmtDay, fmtTime, fmtRelative } from './format'
4041
Added web/src/shared/lib/spa-links.ts View web/src/shared/lib/spa-links.ts
@@ -0,0 +1,42 @@
1import { useCallback, type MouseEvent } from 'react'
2import { useNavigate } from 'react-router-dom'
3
4/** 无协议但形如域名(`www.example.com/x`)的 href 按站外链接处理。 */
5const HOST_LIKE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i
6
7/**
8 * 富文本(Markdown / 服务端 embed HTML)渲染出的链接元素不允许直接跳转:
9 * 一旦放行就是整页刷新。这个处理器是它们唯一的出口——
10 *
11 * - `#fragment`、`mailto:` 等非 http(s) 与带修饰键的点击仍交给浏览器;
12 * - 站内应用路径走 react-router(SPA 内部切换);
13 * - 站外地址与 `/api/` 下载地址开新标签页。
14 */
15export function useSpaLinkClick() {
16 const navigate = useNavigate()
17 return useCallback(
18 (event: MouseEvent<HTMLElement>) => {
19 if (event.defaultPrevented || event.button !== 0) return
20 // 修饰键是「新标签打开」的浏览器约定:放行即可,当前文档不会刷新。
21 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
22 const target = event.target instanceof Element ? event.target : null
23 const href = target?.closest('a')?.getAttribute('href')
24 if (!href || href.startsWith('#')) return
25 const external = !href.startsWith('/') && HOST_LIKE.test(href.split(/[/?#]/, 1)[0])
26 let url: URL
27 try {
28 url = new URL(external ? `https://${href}` : href, window.location.href)
29 } catch {
30 return
31 }
32 if (url.protocol !== 'http:' && url.protocol !== 'https:') return
33 event.preventDefault()
34 if (!external && url.origin === window.location.origin && !url.pathname.startsWith('/api/')) {
35 navigate(`${url.pathname}${url.search}${url.hash}`)
36 return
37 }
38 window.open(url.href, '_blank', 'noopener,noreferrer')
39 },
40 [navigate],
41 )
42}
No newline at end of file
Modified web/src/shared/ui/markdown.tsx View web/src/shared/ui/markdown.tsx
@@ -1,6 +1,7 @@
11import { useMemo } from 'react'
22import DOMPurify from 'dompurify'
33import { marked } from 'marked'
4import { useSpaLinkClick } from '@/shared/lib'
45
56marked.use({ gfm: true, breaks: true })
67
@@ -35,10 +36,13 @@
3536 },
3637 [children, inline],
3738 )
39 // 渲染出的链接不允许直接跳转:站内走 react-router,站外开新标签页。
40 const onClick = useSpaLinkClick()
3841 const Tag = inline ? 'span' : 'div'
3942 return (
4043 <Tag
4144 className={`gitdata-markdown ${compact ? 'gitdata-markdown-compact' : ''} ${className}`}
45 onClick={onClick}
4246 dangerouslySetInnerHTML={{ __html: html }}
4347 />
4448 )