169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
"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<HTMLSpanElement>(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 (
|
|
<span {...props} ref={ref}>
|
|
{children}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
|
<ReactMarkdown
|
|
components={markdownComponents}
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={rehypePlugins}
|
|
>
|
|
{children}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
};
|