mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-17 13:22:42 +01:00
Made copy button and cmd+c work for cmd+v and cmd+shift+v (#3693)
* Made copy button and cmd+c work for cmd+v and cmd+shift+v * made sub selections work as well * ok it works * fixed npm run build * im not from earth * added logging * more logging * bye logs * should work now * whoops * added stuff * made it robust * ctrl shift v behavior
This commit is contained in:
parent
af953ff8a3
commit
da43abe644
11
web/jest.config.js
Normal file
11
web/jest.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"],
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
},
|
||||
};
|
2773
web/package-lock.json
generated
2773
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,8 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
@ -84,10 +85,13 @@
|
||||
"@chromatic-com/playwright": "^0.10.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/chrome": "^0.0.287",
|
||||
"@types/jest": "^29.5.14",
|
||||
"chromatic": "^11.18.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"prettier": "2.8.8"
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "2.8.8",
|
||||
"ts-jest": "^29.2.5"
|
||||
},
|
||||
"overrides": {
|
||||
"react-is": "^19.0.0-rc-69d4b800-20241021"
|
||||
|
@ -9,8 +9,6 @@ import {
|
||||
} from "react-icons/fi";
|
||||
import { FeedbackType } from "../types";
|
||||
import React, {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@ -21,7 +19,10 @@ import React, {
|
||||
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 { CopyButton } from "@/components/CopyButton";
|
||||
@ -37,12 +38,10 @@ import { DocumentPreview } from "../files/documents/DocumentPreview";
|
||||
import { InMessageImage } from "../files/images/InMessageImage";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import rehypePrism from "rehype-prism-plus";
|
||||
|
||||
import "prismjs/themes/prism-tomorrow.css";
|
||||
import "./custom-code-styles.css";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
|
||||
|
||||
import { LikeFeedback, DislikeFeedback } from "@/components/icons/icons";
|
||||
import {
|
||||
CustomTooltip,
|
||||
@ -68,7 +67,6 @@ import CsvContent from "../../../components/tools/CSVContent";
|
||||
import SourceCard, {
|
||||
SeeMoreBlock,
|
||||
} from "@/components/chat_search/sources/SourceCard";
|
||||
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
@ -373,15 +371,28 @@ export const AIMessage = ({
|
||||
);
|
||||
|
||||
const renderedMarkdown = useMemo(() => {
|
||||
if (typeof finalContent !== "string") {
|
||||
return finalContent;
|
||||
}
|
||||
|
||||
// Create a hidden div with the HTML content for copying
|
||||
const htmlContent = markdownToHtml(finalContent);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className="prose max-w-full text-base"
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]}
|
||||
>
|
||||
{finalContent as string}
|
||||
</ReactMarkdown>
|
||||
<>
|
||||
<div
|
||||
style={{ position: "absolute", left: "-9999px" }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
<ReactMarkdown
|
||||
className="prose max-w-full text-base"
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]}
|
||||
>
|
||||
{finalContent}
|
||||
</ReactMarkdown>
|
||||
</>
|
||||
);
|
||||
}, [finalContent, markdownComponents]);
|
||||
|
||||
@ -513,7 +524,68 @@ export const AIMessage = ({
|
||||
|
||||
{typeof content === "string" ? (
|
||||
<div className="overflow-x-visible max-w-content-max">
|
||||
{renderedMarkdown}
|
||||
<div
|
||||
contentEditable="true"
|
||||
suppressContentEditableWarning
|
||||
className="focus:outline-none cursor-text select-text"
|
||||
style={{
|
||||
MozUserModify: "read-only",
|
||||
WebkitUserModify: "read-only",
|
||||
}}
|
||||
onCopy={(e) => {
|
||||
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]);
|
||||
}}
|
||||
>
|
||||
{renderedMarkdown}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
@ -559,7 +631,16 @@ export const AIMessage = ({
|
||||
)}
|
||||
</div>
|
||||
<CustomTooltip showTick line content="Copy">
|
||||
<CopyButton content={content.toString()} />
|
||||
<CopyButton
|
||||
content={
|
||||
typeof content === "string"
|
||||
? {
|
||||
html: markdownToHtml(content),
|
||||
plainText: content,
|
||||
}
|
||||
: content.toString()
|
||||
}
|
||||
/>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip showTick line content="Good response">
|
||||
<HoverableIcon
|
||||
@ -644,7 +725,16 @@ export const AIMessage = ({
|
||||
)}
|
||||
</div>
|
||||
<CustomTooltip showTick line content="Copy">
|
||||
<CopyButton content={content.toString()} />
|
||||
<CopyButton
|
||||
content={
|
||||
typeof content === "string"
|
||||
? {
|
||||
html: markdownToHtml(content),
|
||||
plainText: content,
|
||||
}
|
||||
: content.toString()
|
||||
}
|
||||
/>
|
||||
</CustomTooltip>
|
||||
|
||||
<CustomTooltip showTick line content="Good response">
|
||||
|
176
web/src/app/chat/message/__tests__/codeUtils.test.ts
Normal file
176
web/src/app/chat/message/__tests__/codeUtils.test.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { markdownToHtml, parseMarkdownToSegments } from "../codeUtils";
|
||||
|
||||
describe("markdownToHtml", () => {
|
||||
test("converts bold text with asterisks and underscores", () => {
|
||||
expect(markdownToHtml("This is **bold** text")).toBe(
|
||||
"<p>This is <strong>bold</strong> text</p>"
|
||||
);
|
||||
expect(markdownToHtml("This is __bold__ text")).toBe(
|
||||
"<p>This is <strong>bold</strong> text</p>"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts italic text with asterisks and underscores", () => {
|
||||
expect(markdownToHtml("This is *italic* text")).toBe(
|
||||
"<p>This is <em>italic</em> text</p>"
|
||||
);
|
||||
expect(markdownToHtml("This is _italic_ text")).toBe(
|
||||
"<p>This is <em>italic</em> text</p>"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles mixed bold and italic", () => {
|
||||
expect(markdownToHtml("This is **bold** and *italic* text")).toBe(
|
||||
"<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
);
|
||||
expect(markdownToHtml("This is __bold__ and _italic_ text")).toBe(
|
||||
"<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles text with spaces and special characters", () => {
|
||||
expect(markdownToHtml("This is *as delicious and* tasty")).toBe(
|
||||
"<p>This is <em>as delicious and</em> tasty</p>"
|
||||
);
|
||||
expect(markdownToHtml("This is _as delicious and_ tasty")).toBe(
|
||||
"<p>This is <em>as delicious and</em> tasty</p>"
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
"<p>Sure! Here is a sentence with one italicized word:</p>\n<p>The cake was <em>delicious</em> and everyone enjoyed it.</p>"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles malformed markdown without crashing", () => {
|
||||
expect(markdownToHtml("This is *malformed markdown")).toBe(
|
||||
"<p>This is *malformed markdown</p>"
|
||||
);
|
||||
expect(markdownToHtml("This is _also malformed")).toBe(
|
||||
"<p>This is _also malformed</p>"
|
||||
);
|
||||
expect(markdownToHtml("This has **unclosed bold")).toBe(
|
||||
"<p>This has **unclosed bold</p>"
|
||||
);
|
||||
expect(markdownToHtml("This has __unclosed bold")).toBe(
|
||||
"<p>This has __unclosed bold</p>"
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
@ -82,3 +82,252 @@ 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, "<strong>$2</strong>") // Bold with ** or __, non-greedy and no nesting
|
||||
.replace(/(\*|_)([^*_\n]+?)\1(?!\*|_)/g, "<em>$2</em>"); // Italic with * or _
|
||||
|
||||
// Handle code blocks and links
|
||||
const withCodeAndLinks = processedContent
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>") // Inline code
|
||||
.replace(
|
||||
/```(\w*)\n([\s\S]*?)```/g,
|
||||
(_, lang, code) =>
|
||||
`<pre><code class="language-${lang}">${code.trim()}</code></pre>`
|
||||
) // Code blocks
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); // Links
|
||||
|
||||
// Handle paragraphs
|
||||
return withCodeAndLinks
|
||||
.split(/\n\n+/)
|
||||
.map((para) => para.trim())
|
||||
.filter((para) => para.length > 0)
|
||||
.map((para) => `<p>${para}</p>`)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
interface MarkdownSegment {
|
||||
type: "text" | "link" | "code" | "bold" | "italic" | "codeblock";
|
||||
text: string; // The visible/plain text
|
||||
raw: string; // The raw markdown including syntax
|
||||
length: number; // Length of the visible text
|
||||
}
|
||||
|
||||
export function parseMarkdownToSegments(markdown: string): MarkdownSegment[] {
|
||||
if (!markdown) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments: MarkdownSegment[] = [];
|
||||
let currentIndex = 0;
|
||||
const maxIterations = markdown.length * 2; // Prevent infinite loops
|
||||
let iterations = 0;
|
||||
|
||||
while (currentIndex < markdown.length && iterations < maxIterations) {
|
||||
iterations++;
|
||||
let matched = false;
|
||||
|
||||
// Check for code blocks first (they take precedence)
|
||||
const codeBlockMatch = markdown
|
||||
.slice(currentIndex)
|
||||
.match(/^```(\w*)\n([\s\S]*?)```/);
|
||||
if (codeBlockMatch && codeBlockMatch[0]) {
|
||||
const [fullMatch, , code] = codeBlockMatch;
|
||||
segments.push({
|
||||
type: "codeblock",
|
||||
text: code || "",
|
||||
raw: fullMatch,
|
||||
length: (code || "").length,
|
||||
});
|
||||
currentIndex += fullMatch.length;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for inline code
|
||||
const inlineCodeMatch = markdown.slice(currentIndex).match(/^`([^`]+)`/);
|
||||
if (inlineCodeMatch && inlineCodeMatch[0]) {
|
||||
const [fullMatch, code] = inlineCodeMatch;
|
||||
segments.push({
|
||||
type: "code",
|
||||
text: code || "",
|
||||
raw: fullMatch,
|
||||
length: (code || "").length,
|
||||
});
|
||||
currentIndex += fullMatch.length;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for links
|
||||
const linkMatch = markdown
|
||||
.slice(currentIndex)
|
||||
.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch && linkMatch[0]) {
|
||||
const [fullMatch, text] = linkMatch;
|
||||
segments.push({
|
||||
type: "link",
|
||||
text: text || "",
|
||||
raw: fullMatch,
|
||||
length: (text || "").length,
|
||||
});
|
||||
currentIndex += fullMatch.length;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for bold
|
||||
const boldMatch = markdown
|
||||
.slice(currentIndex)
|
||||
.match(/^(\*\*|__)([^*_\n]*?)\1/);
|
||||
if (boldMatch && boldMatch[0]) {
|
||||
const [fullMatch, , text] = boldMatch;
|
||||
segments.push({
|
||||
type: "bold",
|
||||
text: text || "",
|
||||
raw: fullMatch,
|
||||
length: (text || "").length,
|
||||
});
|
||||
currentIndex += fullMatch.length;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for italic
|
||||
const italicMatch = markdown
|
||||
.slice(currentIndex)
|
||||
.match(/^(\*|_)([^*_\n]+?)\1(?!\*|_)/);
|
||||
if (italicMatch && italicMatch[0]) {
|
||||
const [fullMatch, , text] = italicMatch;
|
||||
segments.push({
|
||||
type: "italic",
|
||||
text: text || "",
|
||||
raw: fullMatch,
|
||||
length: (text || "").length,
|
||||
});
|
||||
currentIndex += fullMatch.length;
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no matches were found, handle regular text
|
||||
if (!matched) {
|
||||
let nextSpecialChar = markdown.slice(currentIndex).search(/[`\[*_]/);
|
||||
if (nextSpecialChar === -1) {
|
||||
// No more special characters, add the rest as text
|
||||
const text = markdown.slice(currentIndex);
|
||||
if (text) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
text: text,
|
||||
raw: text,
|
||||
length: text.length,
|
||||
});
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
// Add the text up to the next special character
|
||||
const text = markdown.slice(
|
||||
currentIndex,
|
||||
currentIndex + nextSpecialChar
|
||||
);
|
||||
if (text) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
text: text,
|
||||
raw: text,
|
||||
length: text.length,
|
||||
});
|
||||
}
|
||||
currentIndex += nextSpecialChar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function getMarkdownForSelection(
|
||||
content: string,
|
||||
selectedText: string
|
||||
): string {
|
||||
const segments = parseMarkdownToSegments(content);
|
||||
|
||||
// Build plain text and create mapping to markdown segments
|
||||
let plainText = "";
|
||||
const markdownPieces: string[] = [];
|
||||
let currentPlainIndex = 0;
|
||||
|
||||
segments.forEach((segment) => {
|
||||
plainText += segment.text;
|
||||
markdownPieces.push(segment.raw);
|
||||
currentPlainIndex += segment.length;
|
||||
});
|
||||
|
||||
// Find the selection in the plain text
|
||||
const startIndex = plainText.indexOf(selectedText);
|
||||
if (startIndex === -1) {
|
||||
return selectedText;
|
||||
}
|
||||
|
||||
const endIndex = startIndex + selectedText.length;
|
||||
|
||||
// Find which segments the selection spans
|
||||
let currentIndex = 0;
|
||||
let result = "";
|
||||
let selectionStart = startIndex;
|
||||
let selectionEnd = endIndex;
|
||||
|
||||
segments.forEach((segment) => {
|
||||
const segmentStart = currentIndex;
|
||||
const segmentEnd = segmentStart + segment.length;
|
||||
|
||||
// Check if this segment overlaps with the selection
|
||||
if (segmentEnd > selectionStart && segmentStart < selectionEnd) {
|
||||
// Calculate how much of this segment to include
|
||||
const overlapStart = Math.max(0, selectionStart - segmentStart);
|
||||
const overlapEnd = Math.min(segment.length, selectionEnd - segmentStart);
|
||||
|
||||
if (segment.type === "text") {
|
||||
const textPortion = segment.text.slice(overlapStart, overlapEnd);
|
||||
result += textPortion;
|
||||
} else {
|
||||
// For markdown elements, wrap just the selected portion with the appropriate markdown
|
||||
const selectedPortion = segment.text.slice(overlapStart, overlapEnd);
|
||||
|
||||
switch (segment.type) {
|
||||
case "bold":
|
||||
result += `**${selectedPortion}**`;
|
||||
break;
|
||||
case "italic":
|
||||
result += `*${selectedPortion}*`;
|
||||
break;
|
||||
case "code":
|
||||
result += `\`${selectedPortion}\``;
|
||||
break;
|
||||
case "link":
|
||||
// For links, we need to preserve the URL if it exists in the raw markdown
|
||||
const urlMatch = segment.raw.match(/\]\((.*?)\)/);
|
||||
const url = urlMatch ? urlMatch[1] : "";
|
||||
result += `[${selectedPortion}](${url})`;
|
||||
break;
|
||||
case "codeblock":
|
||||
result += `\`\`\`\n${selectedPortion}\n\`\`\``;
|
||||
break;
|
||||
default:
|
||||
result += selectedPortion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex += segment.length;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -1,23 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { FiCheck, FiCopy } from "react-icons/fi";
|
||||
import { Hoverable, HoverableIcon } from "./Hoverable";
|
||||
import { HoverableIcon } from "./Hoverable";
|
||||
import { CheckmarkIcon, CopyMessageIcon } from "./icons/icons";
|
||||
|
||||
export function CopyButton({
|
||||
content,
|
||||
onClick,
|
||||
}: {
|
||||
content?: string;
|
||||
content?: string | { html: string; plainText: string };
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [copyClicked, setCopyClicked] = useState(false);
|
||||
|
||||
const copyToClipboard = async (
|
||||
content: string | { html: string; plainText: string }
|
||||
) => {
|
||||
try {
|
||||
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" }
|
||||
),
|
||||
});
|
||||
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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverableIcon
|
||||
icon={copyClicked ? <CheckmarkIcon /> : <CopyMessageIcon />}
|
||||
onClick={() => {
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content.toString());
|
||||
copyToClipboard(content);
|
||||
}
|
||||
onClick && onClick();
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user