"use client"; import React from "react"; import type { Element, Root, RootContent, Text } from "hast"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); const isTextNode = (node: RootContent): node is Text => node.type === "text"; const isElementNode = (node: RootContent): node is Element => node.type === "element"; const createFadeSpan = (value: string, fadeKey: string): Element => ({ type: "element", tagName: "span", properties: { className: [markdownStyles.streamFade], dataStreamFadeKey: fadeKey, dataStreamRevealLength: value.length, }, children: [{ type: "text", value }], }); const splitTextTail = (value: string, tailLength: number) => { const codePoints = Array.from(value); const stableText = codePoints.slice(0, -tailLength).join(""); const animatedText = codePoints.slice(-tailLength).join(""); return { stableText, animatedText }; }; const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => { return () => (tree: Root) => { let remainingFadeLength = fadeLength; const visitChildren = (parent: Element | Root) => { const nextChildren: RootContent[] = []; for (let index = parent.children.length - 1; index >= 0; index -= 1) { const child = (parent.children as RootContent[])[index]; if (isTextNode(child)) { if (!child.value.trim()) { nextChildren.unshift(child); continue; } if (remainingFadeLength <= 0) { nextChildren.unshift(child); continue; } const textLength = Array.from(child.value).length; const tailLength = Math.min(textLength, remainingFadeLength); const { stableText, animatedText } = splitTextTail(child.value, tailLength); remainingFadeLength -= tailLength; if (animatedText) { nextChildren.unshift(createFadeSpan(animatedText, fadeKey)); } if (stableText) { nextChildren.unshift({ ...child, value: stableText }); } continue; } if (isElementNode(child)) { visitChildren(child); } nextChildren.unshift(child); } parent.children = nextChildren as typeof parent.children; }; visitChildren(tree); }; }; const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => { const ref = React.useRef(null); const fadeKeyValue = node?.properties?.dataStreamFadeKey; const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined; const revealLengthValue = node?.properties?.dataStreamRevealLength; const revealLength = typeof revealLengthValue === "number" ? revealLengthValue : typeof revealLengthValue === "string" ? Number.parseInt(revealLengthValue, 10) : 0; React.useLayoutEffect(() => { if (!fadeKey) return; const element = ref.current; if (!element) return; if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; const duration = Math.min(260, Math.max(120, revealLength * 14)); const animation = element.animate( [ { clipPath: "inset(0 100% 0 0)", opacity: 0.46 }, { clipPath: "inset(0 0% 0 0)", opacity: 1 }, ], { duration, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "both", }, ); return () => { animation.cancel(); }; }, [fadeKey, revealLength]); return ( {children} ); }; const markdownComponents: Components = { span: StreamFadeSpan, }; export const MarkdownBlock = ({ children, streamFadeKey, streamFadeLength, }: { children: string; streamFadeKey?: string; streamFadeLength?: number | null; }) => { const handleCopy = React.useCallback((event: React.ClipboardEvent) => { const selectedText = window.getSelection()?.toString(); if (!selectedText) return; event.preventDefault(); event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); }, []); const rehypePlugins = React.useMemo( () => typeof streamFadeLength === "number" && streamFadeLength > 0 ? [createStreamFadePlugin( streamFadeLength, streamFadeKey ?? `stream-tail-${children.length}`, )] : [], [children.length, streamFadeKey, streamFadeLength], ); return (
{children}
); };