evOo-local/packages/chat-ui/src/markdown.web.tsx

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";