fix(web): chat links open in a new tab and long URLs/CJK wrap cleanly (#27)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 21:34:34 +08:00
committed by GitHub
parent 6d9a631e62
commit cb22cd5d97
6 changed files with 151 additions and 8 deletions
+27 -3
View File
@@ -13,9 +13,9 @@
* Inline code keeps the default rendering (`.md-body code` styling).
*/
import { isValidElement, memo } from "react";
import type { ReactNode } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import type { Components, ExtraProps } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "./code-block";
@@ -42,6 +42,27 @@ function MdPre({ children, streaming }: { children?: ReactNode; streaming: boole
return <pre>{children}</pre>;
}
/**
* Link adapter: every chat link opens in a new tab (`target="_blank"` + `rel="noreferrer"`,
* which also implies `noopener`), unconditionally — including relative and `#anchor` hrefs a
* model may emit — so clicking a reply link never navigates the SPA away from the live
* conversation. All other anchor props react-markdown supplies (`href`, `title` from
* `[text](url "title")`, ...) are forwarded as-is — only its non-DOM `node` prop is stripped —
* and `target`/`rel` sit after the spread so the new-tab behavior always wins.
* Long-URL wrapping is CSS (`.md-body a` in styles.css), not handled here.
*/
function MdLink({
node: _node,
children,
...anchorProps
}: ComponentPropsWithoutRef<"a"> & ExtraProps) {
return (
<a {...anchorProps} target="_blank" rel="noreferrer">
{children}
</a>
);
}
/**
* The two `components` maps, built once at module scope instead of inline per render.
* react-markdown uses `components.pre` as the element **type**, so a fresh arrow each render is
@@ -49,13 +70,16 @@ function MdPre({ children, streaming }: { children?: ReactNode; streaming: boole
* text selection and resetting each block's Copy-button state — ~8 times a second while a reply
* streams, including for blocks that closed long ago. `streaming` is the only thing the adapter
* closes over, so one frozen map per value is enough; the single flip between them happens on
* the settle render, which re-parses the message anyway.
* the settle render, which re-parses the message anyway. The `a` adapter closes over nothing,
* so both maps share the one `MdLink` reference.
*/
const STREAMING_COMPONENTS: Components = {
pre: (props) => <MdPre streaming>{props.children}</MdPre>,
a: MdLink,
};
const SETTLED_COMPONENTS: Components = {
pre: (props) => <MdPre streaming={false}>{props.children}</MdPre>,
a: MdLink,
};
export const Md = memo(function Md({
@@ -106,8 +106,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
{text && (
<div className="anim-msg group my-4 flex flex-col items-end">
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
{/* break-words: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing. */}
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-900 dark:text-gray-100">
{/* wrap-anywhere: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing; unlike break-words it also shrinks min-content, so a pathological token can't stretch the flex bubble itself. Normal words still only break when a token can't fit on a line. */}
<p className="wrap-anywhere whitespace-pre-wrap text-base leading-relaxed text-gray-900 dark:text-gray-100">
{text}
</p>
</div>
+14
View File
@@ -353,10 +353,20 @@
}
.md-body a {
@apply text-brand-700 underline decoration-brand-300 underline-offset-2 transition-colors hover:text-brand-600 dark:text-brand-300 dark:decoration-brand-700;
/* `anywhere` rather than `word-break: break-all`: it only breaks a token that would otherwise
overflow, so long URLs wrap cleanly (filling each line) while short Latin words in mixed
CJK/Latin link text never split mid-word; unlike `break-word` it also counts the break
opportunities toward min-content sizing, so a long link can't blow out flex/table layouts. */
overflow-wrap: anywhere;
}
.md-body code {
@apply rounded bg-gray-100 px-1 py-0.5 text-[0.85em] text-gray-800 dark:bg-gray-800 dark:text-gray-200;
}
/* Inline code in prose often holds long unbroken paths/identifiers: same rationale as the link
rule above (fenced blocks are excluded — .md-body pre scrolls horizontally instead). */
.md-body :not(pre) > code {
overflow-wrap: anywhere;
}
.md-body pre {
@apply overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-800 dark:bg-gray-900;
/* Off-screen code blocks skip layout/paint: with several settled 200KB+ replies in the
@@ -396,6 +406,10 @@ html.dark .shiki span {
}
.md-body table {
border-collapse: collapse;
/* Same rule as docs (packages/docs/src/styles.css): a wide table scrolls horizontally inside
the message body instead of pushing the whole page wide. */
display: block;
overflow-x: auto;
}
.md-body :is(th, td) {
@apply border border-gray-200 px-2 py-1 text-sm dark:border-gray-800;