236 lines
6.3 KiB
TypeScript
236 lines
6.3 KiB
TypeScript
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
import type { Components } from "react-markdown";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import "./markdown.web.css";
|
|
import type { ChatMarkdownProps } from "./markdown";
|
|
import {
|
|
citationDomainLabel,
|
|
closeUnterminatedFence,
|
|
isExternalMarkdownUrl,
|
|
parseCitationBadgeLabel,
|
|
parseHexColorToken,
|
|
sanitizeMarkdownUrl,
|
|
} from "./markdown";
|
|
|
|
function CopyIcon() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<rect
|
|
x="9"
|
|
y="9"
|
|
width="12"
|
|
height="12"
|
|
rx="2"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path
|
|
d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CheckIcon() {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M4 12.5 9.5 18 20 6"
|
|
stroke="currentColor"
|
|
strokeWidth="2.25"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ExternalLinkArrowIcon() {
|
|
return (
|
|
<svg
|
|
className="rk-chat-markdown-external-icon"
|
|
width="11"
|
|
height="11"
|
|
viewBox="0 0 12 12"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M3.5 8.5L8.5 3.5M8.5 3.5H4.25M8.5 3.5V7.75"
|
|
stroke="currentColor"
|
|
strokeWidth="1.65"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ColorSwatchCode({ hex, children }: { hex: string; children: React.ReactNode }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const timerRef = useRef<number | undefined>(undefined);
|
|
|
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!navigator.clipboard) return;
|
|
navigator.clipboard
|
|
.writeText(hex)
|
|
.then(() => {
|
|
setCopied(true);
|
|
window.clearTimeout(timerRef.current);
|
|
timerRef.current = window.setTimeout(() => setCopied(false), 1200);
|
|
})
|
|
.catch(() => {});
|
|
}, [hex]);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
title={copied ? `${hex} ✓` : hex}
|
|
aria-label={hex}
|
|
data-testid="markdown-color-swatch"
|
|
data-color={hex}
|
|
className="rk-chat-markdown-swatch"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="rk-chat-markdown-swatch-dot"
|
|
style={{ backgroundColor: hex }}
|
|
/>
|
|
<code>{children}</code>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">) {
|
|
const preRef = useRef<HTMLPreElement>(null);
|
|
const resetTimerRef = useRef<number | undefined>(undefined);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!navigator.clipboard) return;
|
|
const text = preRef.current?.textContent ?? "";
|
|
navigator.clipboard
|
|
.writeText(text)
|
|
.then(() => {
|
|
setCopied(true);
|
|
window.clearTimeout(resetTimerRef.current);
|
|
resetTimerRef.current = window.setTimeout(() => setCopied(false), 1500);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
return (
|
|
<div className="rk-chat-markdown-pre-wrap">
|
|
<pre {...props} ref={preRef} />
|
|
<button
|
|
type="button"
|
|
className="rk-chat-markdown-copy"
|
|
onClick={handleCopy}
|
|
aria-label={copied ? "Copied" : "Copy code"}
|
|
>
|
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const components: Components = {
|
|
a({ node: _node, children, href, className, ...props }) {
|
|
const external = isExternalMarkdownUrl(href);
|
|
const childText =
|
|
typeof children === "string"
|
|
? children
|
|
: Array.isArray(children) && children.length === 1 && typeof children[0] === "string"
|
|
? children[0]
|
|
: undefined;
|
|
const citationNum = parseCitationBadgeLabel(childText, href);
|
|
if (citationNum) {
|
|
const domain = citationDomainLabel(href);
|
|
return (
|
|
<a
|
|
{...props}
|
|
href={href}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title={domain ? `${domain} — ${href}` : href}
|
|
data-external-link=""
|
|
data-citation-badge={citationNum}
|
|
className={
|
|
className ? `${className} rk-chat-markdown-citation` : "rk-chat-markdown-citation"
|
|
}
|
|
>
|
|
{citationNum}
|
|
</a>
|
|
);
|
|
}
|
|
const linkClass = external
|
|
? className
|
|
? `${className} rk-chat-markdown-external-link`
|
|
: "rk-chat-markdown-external-link"
|
|
: className;
|
|
return (
|
|
<a
|
|
{...props}
|
|
href={href}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
data-external-link={external ? "" : undefined}
|
|
className={linkClass}
|
|
>
|
|
{children}
|
|
{external ? <ExternalLinkArrowIcon /> : null}
|
|
</a>
|
|
);
|
|
},
|
|
code({ node: _node, className, children, ...props }) {
|
|
const rawText = typeof children === "string" ? children : undefined;
|
|
const hex = !className ? parseHexColorToken(rawText) : undefined;
|
|
if (hex) {
|
|
return <ColorSwatchCode hex={hex}>{children}</ColorSwatchCode>;
|
|
}
|
|
return (
|
|
<code {...props} className={className}>
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
img({ node: _node, ...props }) {
|
|
return <img {...props} alt={props.alt ?? ""} loading="lazy" />;
|
|
},
|
|
pre({ node: _node, ...props }) {
|
|
return <CodeBlock {...props} />;
|
|
},
|
|
};
|
|
|
|
export const ChatMarkdown = memo(function ChatMarkdown({
|
|
children,
|
|
streaming = false,
|
|
}: ChatMarkdownProps) {
|
|
const source = streaming ? closeUnterminatedFence(children) : children;
|
|
|
|
return (
|
|
<div className={streaming ? "rk-chat-markdown rk-chat-markdown-streaming" : "rk-chat-markdown"}>
|
|
<ReactMarkdown
|
|
components={components}
|
|
remarkPlugins={[remarkGfm]}
|
|
skipHtml
|
|
urlTransform={(url) => sanitizeMarkdownUrl(url, true) ?? ""}
|
|
>
|
|
{source}
|
|
</ReactMarkdown>
|
|
{streaming ? <span aria-hidden="true" className="rk-chat-markdown-cursor" /> : null}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export type { ChatMarkdownProps } from "./markdown";
|