From 426a8842ae04dcfce5a0ef32e02b0f88db17475d Mon Sep 17 00:00:00 2001 From: pablonyx Date: Tue, 25 Feb 2025 20:56:38 -0800 Subject: [PATCH] Markdown copying / html formatting (#4120) * k * delete unnecessary util --- web/package-lock.json | 89 +++++++++ web/package.json | 2 + web/src/app/chat/message/AgenticMessage.tsx | 38 +++- web/src/app/chat/message/Messages.tsx | 119 +++--------- .../app/chat/message/SubQuestionsDisplay.tsx | 8 +- .../chat/message/__tests__/codeUtils.test.ts | 176 ------------------ web/src/app/chat/message/codeUtils.ts | 29 --- web/src/app/chat/message/copyingUtils.tsx | 71 +++++++ web/src/components/CopyButton.tsx | 37 ++-- 9 files changed, 240 insertions(+), 329 deletions(-) delete mode 100644 web/src/app/chat/message/__tests__/codeUtils.test.ts create mode 100644 web/src/app/chat/message/copyingUtils.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 17eabc6fb..e0fd1e981 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -70,6 +70,8 @@ "recharts": "^2.13.1", "rehype-katex": "^7.0.1", "rehype-prism-plus": "^2.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "semver": "^7.5.4", @@ -11741,6 +11743,54 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html/node_modules/property-information": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz", + "integrity": "sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", @@ -11919,6 +11969,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", @@ -19125,6 +19185,35 @@ "unist-util-visit": "^5.0.0" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", diff --git a/web/package.json b/web/package.json index fc8fc16d4..22d1df72a 100644 --- a/web/package.json +++ b/web/package.json @@ -73,6 +73,8 @@ "recharts": "^2.13.1", "rehype-katex": "^7.0.1", "rehype-prism-plus": "^2.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "semver": "^7.5.4", diff --git a/web/src/app/chat/message/AgenticMessage.tsx b/web/src/app/chat/message/AgenticMessage.tsx index f5a1d1094..6bc114960 100644 --- a/web/src/app/chat/message/AgenticMessage.tsx +++ b/web/src/app/chat/message/AgenticMessage.tsx @@ -7,14 +7,9 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from "react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import ReactMarkdown from "react-markdown"; import { OnyxDocument, FilteredOnyxDocument } from "@/lib/search/interfaces"; import remarkGfm from "remark-gfm"; @@ -54,6 +49,7 @@ import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import SubQuestionsDisplay from "./SubQuestionsDisplay"; import { StatusRefinement } from "../Refinement"; +import { copyAll, handleCopy } from "./copyingUtils"; export const AgenticMessage = ({ isStreamingQuestions, @@ -312,6 +308,8 @@ export const AgenticMessage = ({ [anchorCallback, paragraphCallback, streamedContent] ); + const markdownRef = useRef(null); + const renderedAlternativeMarkdown = useMemo(() => { return ( {typeof content === "string" ? ( -
+
handleCopy(e, markdownRef)} + ref={markdownRef} + className="overflow-x-visible !text-sm max-w-content-max" + > {isViewingInitialAnswer ? renderedMarkdown : renderedAlternativeMarkdown} @@ -558,7 +560,16 @@ export const AgenticMessage = ({ )}
- + + copyAll( + (isViewingInitialAnswer + ? finalContent + : finalAlternativeContent) as string, + markdownRef + ) + } + /> - + + copyAll( + (isViewingInitialAnswer + ? finalContent + : finalAlternativeContent) as string, + markdownRef + ) + } + /> diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 981c29a7a..95af613d2 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -16,15 +16,16 @@ import React, { useRef, useState, } from "react"; +import { unified } from "unified"; import ReactMarkdown from "react-markdown"; import { OnyxDocument, FilteredOnyxDocument } from "@/lib/search/interfaces"; import { SearchSummary } from "./SearchSummary"; -import { - markdownToHtml, - getMarkdownForSelection, -} from "@/app/chat/message/codeUtils"; import { SkippedSearch } from "./SkippedSearch"; import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import rehypeSanitize from "rehype-sanitize"; +import rehypeStringify from "rehype-stringify"; import { CopyButton } from "@/components/CopyButton"; import { ChatFileType, FileDescriptor, ToolCallMetadata } from "../interfaces"; import { @@ -69,6 +70,7 @@ import { SourceCard } from "./SourcesDisplay"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; +import { copyAll, handleCopy } from "./copyingUtils"; const TOOLS_WITH_CUSTOM_HANDLING = [ SEARCH_TOOL_NAME, @@ -364,34 +366,24 @@ export const AIMessage = ({ }), [anchorCallback, paragraphCallback, finalContent] ); + const markdownRef = useRef(null); + + // Process selection copying with HTML formatting const renderedMarkdown = useMemo(() => { if (typeof finalContent !== "string") { return finalContent; } - // Create a hidden div with the HTML content for copying - const htmlContent = markdownToHtml(finalContent); - return ( - <> -
- - {finalContent} - - + + {finalContent} + ); }, [finalContent, markdownComponents]); @@ -535,64 +527,9 @@ export const AIMessage = ({ {typeof content === "string" ? (
{ - e.preventDefault(); - const selection = window.getSelection(); - const selectedPlainText = - selection?.toString() || ""; - if (!selectedPlainText) { - // If no text is selected, copy the full content - const contentStr = - typeof content === "string" - ? content - : ( - content as JSX.Element - ).props?.children?.toString() || ""; - const clipboardItem = new ClipboardItem({ - "text/html": new Blob( - [ - typeof content === "string" - ? markdownToHtml(content) - : contentStr, - ], - { type: "text/html" } - ), - "text/plain": new Blob([contentStr], { - type: "text/plain", - }), - }); - navigator.clipboard.write([clipboardItem]); - return; - } - - const contentStr = - typeof content === "string" - ? content - : ( - content as JSX.Element - ).props?.children?.toString() || ""; - const markdownText = getMarkdownForSelection( - contentStr, - selectedPlainText - ); - const clipboardItem = new ClipboardItem({ - "text/html": new Blob( - [markdownToHtml(markdownText)], - { type: "text/html" } - ), - "text/plain": new Blob([selectedPlainText], { - type: "text/plain", - }), - }); - navigator.clipboard.write([clipboardItem]); - }} + onCopy={(e) => handleCopy(e, markdownRef)} > {renderedMarkdown}
@@ -643,13 +580,8 @@ export const AIMessage = ({
+ copyAll(finalContent as string, markdownRef) } /> @@ -734,13 +666,8 @@ export const AIMessage = ({
+ copyAll(finalContent as string, markdownRef) } /> diff --git a/web/src/app/chat/message/SubQuestionsDisplay.tsx b/web/src/app/chat/message/SubQuestionsDisplay.tsx index e0a26f13b..a4f4c86ec 100644 --- a/web/src/app/chat/message/SubQuestionsDisplay.tsx +++ b/web/src/app/chat/message/SubQuestionsDisplay.tsx @@ -24,6 +24,7 @@ import { CodeBlock } from "./CodeBlock"; import { CheckIcon, ChevronDown } from "lucide-react"; import { PHASE_MIN_MS, useStreamingMessages } from "./StreamingMessages"; import { CirclingArrowIcon } from "@/components/icons/icons"; +import { handleCopy } from "./copyingUtils"; export const StatusIndicator = ({ status }: { status: ToggleState }) => { return ( @@ -292,6 +293,7 @@ const SubQuestionDisplay: React.FC<{ } }, [currentlyClosed]); + const analysisRef = useRef(null); const renderedMarkdown = useMemo(() => { return (
{analysisToggled && ( -
+
handleCopy(e, analysisRef)} + className="flex flex-wrap gap-2" + > {renderedMarkdown}
)} diff --git a/web/src/app/chat/message/__tests__/codeUtils.test.ts b/web/src/app/chat/message/__tests__/codeUtils.test.ts deleted file mode 100644 index 8cf4cb7bc..000000000 --- a/web/src/app/chat/message/__tests__/codeUtils.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { markdownToHtml, parseMarkdownToSegments } from "../codeUtils"; - -describe("markdownToHtml", () => { - test("converts bold text with asterisks and underscores", () => { - expect(markdownToHtml("This is **bold** text")).toBe( - "

This is bold text

" - ); - expect(markdownToHtml("This is __bold__ text")).toBe( - "

This is bold text

" - ); - }); - - test("converts italic text with asterisks and underscores", () => { - expect(markdownToHtml("This is *italic* text")).toBe( - "

This is italic text

" - ); - expect(markdownToHtml("This is _italic_ text")).toBe( - "

This is italic text

" - ); - }); - - test("handles mixed bold and italic", () => { - expect(markdownToHtml("This is **bold** and *italic* text")).toBe( - "

This is bold and italic text

" - ); - expect(markdownToHtml("This is __bold__ and _italic_ text")).toBe( - "

This is bold and italic text

" - ); - }); - - test("handles text with spaces and special characters", () => { - expect(markdownToHtml("This is *as delicious and* tasty")).toBe( - "

This is as delicious and tasty

" - ); - expect(markdownToHtml("This is _as delicious and_ tasty")).toBe( - "

This is as delicious and tasty

" - ); - }); - - test("handles multi-paragraph text with italics", () => { - const input = - "Sure! Here is a sentence with one italicized word:\n\nThe cake was _delicious_ and everyone enjoyed it."; - expect(markdownToHtml(input)).toBe( - "

Sure! Here is a sentence with one italicized word:

\n

The cake was delicious and everyone enjoyed it.

" - ); - }); - - test("handles malformed markdown without crashing", () => { - expect(markdownToHtml("This is *malformed markdown")).toBe( - "

This is *malformed markdown

" - ); - expect(markdownToHtml("This is _also malformed")).toBe( - "

This is _also malformed

" - ); - expect(markdownToHtml("This has **unclosed bold")).toBe( - "

This has **unclosed bold

" - ); - expect(markdownToHtml("This has __unclosed bold")).toBe( - "

This has __unclosed bold

" - ); - }); - - test("handles empty or null input", () => { - expect(markdownToHtml("")).toBe(""); - expect(markdownToHtml(" ")).toBe(""); - expect(markdownToHtml("\n")).toBe(""); - }); - - test("handles extremely long input without crashing", () => { - const longText = "This is *italic* ".repeat(1000); - expect(() => markdownToHtml(longText)).not.toThrow(); - }); -}); - -describe("parseMarkdownToSegments", () => { - test("parses italic text with asterisks", () => { - const segments = parseMarkdownToSegments("This is *italic* text"); - expect(segments).toEqual([ - { type: "text", text: "This is ", raw: "This is ", length: 8 }, - { type: "italic", text: "italic", raw: "*italic*", length: 6 }, - { type: "text", text: " text", raw: " text", length: 5 }, - ]); - }); - - test("parses italic text with underscores", () => { - const segments = parseMarkdownToSegments("This is _italic_ text"); - expect(segments).toEqual([ - { type: "text", text: "This is ", raw: "This is ", length: 8 }, - { type: "italic", text: "italic", raw: "_italic_", length: 6 }, - { type: "text", text: " text", raw: " text", length: 5 }, - ]); - }); - - test("parses bold text with asterisks", () => { - const segments = parseMarkdownToSegments("This is **bold** text"); - expect(segments).toEqual([ - { type: "text", text: "This is ", raw: "This is ", length: 8 }, - { type: "bold", text: "bold", raw: "**bold**", length: 4 }, - { type: "text", text: " text", raw: " text", length: 5 }, - ]); - }); - - test("parses bold text with underscores", () => { - const segments = parseMarkdownToSegments("This is __bold__ text"); - expect(segments).toEqual([ - { type: "text", text: "This is ", raw: "This is ", length: 8 }, - { type: "bold", text: "bold", raw: "__bold__", length: 4 }, - { type: "text", text: " text", raw: " text", length: 5 }, - ]); - }); - - test("parses text with spaces and special characters in italics", () => { - const segments = parseMarkdownToSegments( - "The cake was _delicious_ and everyone enjoyed it." - ); - expect(segments).toEqual([ - { type: "text", text: "The cake was ", raw: "The cake was ", length: 13 }, - { type: "italic", text: "delicious", raw: "_delicious_", length: 9 }, - { - type: "text", - text: " and everyone enjoyed it.", - raw: " and everyone enjoyed it.", - length: 25, - }, - ]); - }); - - test("parses multi-paragraph text with italics", () => { - const segments = parseMarkdownToSegments( - "Sure! Here is a sentence with one italicized word:\n\nThe cake was _delicious_ and everyone enjoyed it." - ); - expect(segments).toEqual([ - { - type: "text", - text: "Sure! Here is a sentence with one italicized word:\n\nThe cake was ", - raw: "Sure! Here is a sentence with one italicized word:\n\nThe cake was ", - length: 65, - }, - { type: "italic", text: "delicious", raw: "_delicious_", length: 9 }, - { - type: "text", - text: " and everyone enjoyed it.", - raw: " and everyone enjoyed it.", - length: 25, - }, - ]); - }); - - test("handles malformed markdown without crashing", () => { - expect(() => parseMarkdownToSegments("This is *malformed")).not.toThrow(); - expect(() => - parseMarkdownToSegments("This is _also malformed") - ).not.toThrow(); - expect(() => - parseMarkdownToSegments("This has **unclosed bold") - ).not.toThrow(); - expect(() => - parseMarkdownToSegments("This has __unclosed bold") - ).not.toThrow(); - }); - - test("handles empty or null input", () => { - expect(parseMarkdownToSegments("")).toEqual([]); - expect(parseMarkdownToSegments(" ")).toEqual([ - { type: "text", text: " ", raw: " ", length: 1 }, - ]); - expect(parseMarkdownToSegments("\n")).toEqual([ - { type: "text", text: "\n", raw: "\n", length: 1 }, - ]); - }); - - test("handles extremely long input without crashing", () => { - const longText = "This is *italic* ".repeat(1000); - expect(() => parseMarkdownToSegments(longText)).not.toThrow(); - }); -}); diff --git a/web/src/app/chat/message/codeUtils.ts b/web/src/app/chat/message/codeUtils.ts index efb13f1f9..9d329c05f 100644 --- a/web/src/app/chat/message/codeUtils.ts +++ b/web/src/app/chat/message/codeUtils.ts @@ -83,35 +83,6 @@ export const preprocessLaTeX = (content: string) => { return inlineProcessedContent; }; -export const markdownToHtml = (content: string): string => { - if (!content || !content.trim()) { - return ""; - } - - // Basic markdown to HTML conversion for common patterns - const processedContent = content - .replace(/(\*\*|__)((?:(?!\1).)*?)\1/g, "$2") // Bold with ** or __, non-greedy and no nesting - .replace(/(\*|_)([^*_\n]+?)\1(?!\*|_)/g, "$2"); // Italic with * or _ - - // Handle code blocks and links - const withCodeAndLinks = processedContent - .replace(/`([^`]+)`/g, "$1") // Inline code - .replace( - /```(\w*)\n([\s\S]*?)```/g, - (_, lang, code) => - `
${code.trim()}
` - ) // Code blocks - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // Links - - // Handle paragraphs - return withCodeAndLinks - .split(/\n\n+/) - .map((para) => para.trim()) - .filter((para) => para.length > 0) - .map((para) => `

${para}

`) - .join("\n"); -}; - interface MarkdownSegment { type: "text" | "link" | "code" | "bold" | "italic" | "codeblock"; text: string; // The visible/plain text diff --git a/web/src/app/chat/message/copyingUtils.tsx b/web/src/app/chat/message/copyingUtils.tsx new file mode 100644 index 000000000..35768a59b --- /dev/null +++ b/web/src/app/chat/message/copyingUtils.tsx @@ -0,0 +1,71 @@ +"use client"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import remarkRehype from "remark-rehype"; +import rehypePrism from "rehype-prism-plus"; +import rehypeKatex from "rehype-katex"; +import rehypeSanitize from "rehype-sanitize"; +import rehypeStringify from "rehype-stringify"; + +export const handleCopy = ( + e: React.ClipboardEvent, + markdownRef: React.RefObject +) => { + // Check if we have a selection + const selection = window.getSelection(); + if (!selection?.rangeCount) return; + + const range = selection.getRangeAt(0); + + // If selection is within our markdown container + if ( + markdownRef.current && + markdownRef.current.contains(range.commonAncestorContainer) + ) { + e.preventDefault(); + + // Clone selection to get the HTML + const fragment = range.cloneContents(); + const tempDiv = document.createElement("div"); + tempDiv.appendChild(fragment); + + // Create clipboard data with both HTML and plain text + e.clipboardData.setData("text/html", tempDiv.innerHTML); + e.clipboardData.setData("text/plain", selection.toString()); + } +}; + +// For copying the entire content +export const copyAll = ( + content: string, + markdownRef: React.RefObject +) => { + if (!markdownRef.current || typeof content !== "string") { + return; + } + + // Convert markdown to HTML using unified ecosystem + unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .use(remarkRehype) + .use(rehypePrism, { ignoreMissing: true }) + .use(rehypeKatex) + .use(rehypeSanitize) + .use(rehypeStringify) + .process(content) + .then((file: any) => { + const htmlContent = String(file); + + // Create clipboard data + const clipboardItem = new ClipboardItem({ + "text/html": new Blob([htmlContent], { type: "text/html" }), + "text/plain": new Blob([content], { type: "text/plain" }), + }); + + navigator.clipboard.write([clipboardItem]); + }); +}; diff --git a/web/src/components/CopyButton.tsx b/web/src/components/CopyButton.tsx index c2f8dafed..24a497958 100644 --- a/web/src/components/CopyButton.tsx +++ b/web/src/components/CopyButton.tsx @@ -4,33 +4,36 @@ import { CheckmarkIcon, CopyMessageIcon } from "./icons/icons"; export function CopyButton({ content, + copyAllFn, onClick, }: { - content?: string | { html: string; plainText: string }; + content?: string; + copyAllFn?: () => void; onClick?: () => void; }) { const [copyClicked, setCopyClicked] = useState(false); - const copyToClipboard = async ( - content: string | { html: string; plainText: string } - ) => { + const copyToClipboard = async () => { try { + // If copyAllFn is provided, use it instead of the default behavior + if (copyAllFn) { + await copyAllFn(); + return; + } + + // Fall back to original behavior if no copyAllFn is provided + if (!content) return; + const clipboardItem = new ClipboardItem({ - "text/html": new Blob( - [typeof content === "string" ? content : content.html], - { type: "text/html" } - ), - "text/plain": new Blob( - [typeof content === "string" ? content : content.plainText], - { type: "text/plain" } - ), + "text/html": new Blob([content], { type: "text/html" }), + "text/plain": new Blob([content], { type: "text/plain" }), }); await navigator.clipboard.write([clipboardItem]); } catch (err) { // Fallback to basic text copy if HTML copy fails - await navigator.clipboard.writeText( - typeof content === "string" ? content : content.plainText - ); + if (content) { + await navigator.clipboard.writeText(content); + } } }; @@ -38,9 +41,7 @@ export function CopyButton({ : } onClick={() => { - if (content) { - copyToClipboard(content); - } + copyToClipboard(); onClick && onClick(); setCopyClicked(true);