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:
hagen-danswer 2025-01-18 10:34:32 -08:00 committed by GitHub
parent af953ff8a3
commit da43abe644
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 3347 additions and 24 deletions

11
web/jest.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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();
});
});

View File

@ -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;
}

View File

@ -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();