diff --git a/client/src/hooks/Messages/useCopyToClipboard.spec.ts b/client/src/hooks/Messages/useCopyToClipboard.spec.ts index 6e0844100aa8..920578537afb 100644 --- a/client/src/hooks/Messages/useCopyToClipboard.spec.ts +++ b/client/src/hooks/Messages/useCopyToClipboard.spec.ts @@ -37,9 +37,7 @@ describe('useCopyToClipboard', () => { result.current(mockSetIsCopied); }); - expect(mockCopy).toHaveBeenCalledWith('Simple text without citations', { - format: 'text/plain', - }); + expect(mockCopy).toHaveBeenCalledWith('Simple text without citations', expect.objectContaining({ format: 'text/plain' })); expect(mockSetIsCopied).toHaveBeenCalledWith(true); }); @@ -59,9 +57,7 @@ describe('useCopyToClipboard', () => { result.current(mockSetIsCopied); }); - expect(mockCopy).toHaveBeenCalledWith('First line\nSecond line', { - format: 'text/plain', - }); + expect(mockCopy).toHaveBeenCalledWith('First line\nSecond line', expect.objectContaining({ format: 'text/plain' })); }); it('should reset isCopied after timeout', () => { @@ -83,6 +79,31 @@ describe('useCopyToClipboard', () => { expect(mockSetIsCopied).toHaveBeenCalledWith(false); }); + + it('should convert markdown tables to tab-separated text for spreadsheet paste', () => { + const text = `| Name | Age | Occupation | +| ------- | --- | ---------- | +| Michael | 35 | Engineer | +| Sarah | 28 | Doctor | +| Tracy | 45 | Teacher |`; + + const { result } = renderHook(() => + useCopyToClipboard({ + text, + }), + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + const expectedText = `Name\tAge\tOccupation +Michael\t35\tEngineer +Sarah\t28\tDoctor +Tracy\t45\tTeacher`; + + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); + }); }); describe('Citation formatting', () => { @@ -140,7 +161,7 @@ Citations: [1] https://example.com/search1 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); it('should format news citations with correct mapping', () => { @@ -164,7 +185,7 @@ Citations: [2] https://example.com/news2 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); it('should handle highlighted text with citations', () => { @@ -181,13 +202,43 @@ Citations: result.current(mockSetIsCopied); }); - const expectedText = `**This is highlighted text** [1] with citation. + const expectedText = `This is highlighted text [1] with citation. Citations: [1] https://example.com/search1 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); + }); + + it('should preserve citation output while converting markdown tables', () => { + const text = `| Name | Score | +| --- | --- | +| John | 91 | + +Source \\ue202turn0search0`; + + const { result } = renderHook(() => + useCopyToClipboard({ + text, + searchResults: mockSearchResults, + }), + ); + + act(() => { + result.current(mockSetIsCopied); + }); + + const expectedText = `Name\tScore +John\t91 + +Source [1] + +Citations: +[1] https://example.com/search1 +`; + + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); it('should handle composite citations', () => { @@ -213,7 +264,7 @@ Citations: [3] https://example.com/news2 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); }); @@ -255,7 +306,7 @@ Citations: [1] https://example.com/article `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); it('should handle multiple citations of the same source', () => { @@ -290,7 +341,7 @@ Citations: [1] https://example.com/source1 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); }); @@ -310,9 +361,7 @@ Citations: }); // Updated expectation: Citation marker should be removed - expect(mockCopy).toHaveBeenCalledWith('Text with citation but no data.', { - format: 'text/plain', - }); + expect(mockCopy).toHaveBeenCalledWith('Text with citation but no data.', expect.objectContaining({ format: 'text/plain' })); }); it('should handle invalid citation indices', () => { @@ -347,7 +396,7 @@ Citations: [1] https://example.com/search1 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); it('should handle citations without links', () => { @@ -376,9 +425,7 @@ Citations: }); // Updated expectation: Citation marker without link should be removed - expect(mockCopy).toHaveBeenCalledWith('Citation without link.', { - format: 'text/plain', - }); + expect(mockCopy).toHaveBeenCalledWith('Citation without link.', expect.objectContaining({ format: 'text/plain' })); }); it('should clean up orphaned citation lists at the end', () => { @@ -410,7 +457,7 @@ Citations: [1] https://example.com/1 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); }); @@ -450,7 +497,7 @@ Citations: [5] https://example.com/ref `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); }); @@ -480,7 +527,7 @@ Citations: result.current(mockSetIsCopied); }); - const expectedText = `**Highlighted text with citation** [1] and composite [2][3]. + const expectedText = `Highlighted text with citation [1] and composite [2][3]. Citations: [1] https://example.com/1 @@ -488,7 +535,7 @@ Citations: [3] https://example.com/3 `; - expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' }); + expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' })); }); }); }); diff --git a/client/src/hooks/Messages/useCopyToClipboard.ts b/client/src/hooks/Messages/useCopyToClipboard.ts index 501ac9420328..27dc57e9c38b 100644 --- a/client/src/hooks/Messages/useCopyToClipboard.ts +++ b/client/src/hooks/Messages/useCopyToClipboard.ts @@ -56,7 +56,9 @@ export default function useCopyToClipboard({ if (content) { messageText = content.reduce((acc, curr, i) => { if (curr.type === ContentTypes.TEXT) { - const text = typeof curr.text === 'string' ? curr.text : curr.text.value; + const textPart = curr.text; + const text = + typeof textPart === 'string' ? textPart : (textPart?.value ?? textPart?.toString()) || ''; return acc + text + (i === content.length - 1 ? '' : '\n'); } return acc; @@ -69,8 +71,16 @@ export default function useCopyToClipboard({ const cleanedText = messageText .replace(INVALID_CITATION_REGEX, '') .replace(CLEANUP_REGEX, ''); - - copy(cleanedText, { format: 'text/plain' }); + const markdownText = cleanedText; + const plainText = normalizeClipboardPlainText(transformMarkdownTablesToTSV(markdownText)); + const htmlText = buildClipboardHTML(markdownText); + + copy(plainText, { + format: 'text/plain', + onCopy: (clipboardData) => { + setHTMLClipboardData(clipboardData, htmlText); + }, + }); copyTimeoutRef.current = setTimeout(() => { setIsCopied(false); }, 3000); @@ -95,7 +105,14 @@ export default function useCopyToClipboard({ } } - copy(processedText, { format: 'text/plain' }); + const plainText = normalizeClipboardPlainText(transformMarkdownTablesToTSV(processedText)); + const htmlText = buildClipboardHTML(processedText); + copy(plainText, { + format: 'text/plain', + onCopy: (clipboardData) => { + setHTMLClipboardData(clipboardData, htmlText); + }, + }); copyTimeoutRef.current = setTimeout(() => { setIsCopied(false); }, 3000); @@ -341,3 +358,138 @@ function processCitations(text: string, searchResults: { [key: string]: SearchRe citations, }; } + +function transformMarkdownTablesToTSV(text: string): string { + const lines = text.split('\n'); + const transformedLines: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + const nextLine = lines[index + 1]; + + if (isTableRow(line) && isSeparatorRow(nextLine)) { + transformedLines.push(convertTableRowToTSV(line)); + index += 2; + + while (index < lines.length && isTableRow(lines[index])) { + transformedLines.push(convertTableRowToTSV(lines[index])); + index += 1; + } + + continue; + } + + transformedLines.push(line); + index += 1; + } + + return transformedLines.join('\n'); +} + +function isTableRow(line?: string): boolean { + if (!line) { + return false; + } + + const trimmed = line.trim(); + return trimmed.includes('|') && trimmed.replace(/\|/g, '').trim().length > 0; +} + +function isSeparatorRow(line?: string): boolean { + if (!line) { + return false; + } + + const normalized = line + .trim() + .replace(/\|/g, '') + .replace(/:/g, '') + .replace(/-/g, '') + .replace(/\s/g, ''); + if (normalized.length > 0) { + return false; + } + + return line.includes('|') && line.includes('-'); +} + +function convertTableRowToTSV(row: string): string { + const trimmed = row.trim(); + const noBoundaryPipes = trimmed.replace(/^\|/, '').replace(/\|$/, ''); + return noBoundaryPipes + .split('|') + .map((cell) => cell.trim()) + .join('\t'); +} + +function normalizeClipboardPlainText(text: string): string { + return text.replace(/\*\*(.+?)\*\*/g, '$1'); +} + +function buildClipboardHTML(markdownText: string): string { + const lines = markdownText.split('\n'); + const htmlParts: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + const nextLine = lines[index + 1]; + + if (isTableRow(line) && isSeparatorRow(nextLine)) { + const headers = parseTableCells(line); + index += 2; + const rows: string[][] = []; + + while (index < lines.length && isTableRow(lines[index])) { + rows.push(parseTableCells(lines[index])); + index += 1; + } + + const tableHeader = `${headers.map((cell) => `${inlineMarkdownToHTML(cell)}`).join('')}`; + const tableBody = `${rows + .map((row) => `${row.map((cell) => `${inlineMarkdownToHTML(cell)}`).join('')}`) + .join('')}`; + htmlParts.push(`${tableHeader}${tableBody}
`); + continue; + } + + if (line.trim().length === 0) { + htmlParts.push('
'); + } else { + htmlParts.push(`

${inlineMarkdownToHTML(line)}

`); + } + index += 1; + } + + return `
${htmlParts.join('')}
`; +} + +function parseTableCells(row: string): string[] { + const trimmed = row.trim(); + const noBoundaryPipes = trimmed.replace(/^\|/, '').replace(/\|$/, ''); + return noBoundaryPipes.split('|').map((cell) => cell.trim()); +} + +function inlineMarkdownToHTML(text: string): string { + const escaped = escapeHTML(text); + return escaped.replace(/\*\*(.+?)\*\*/g, '$1'); +} + +function escapeHTML(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function setHTMLClipboardData(clipboardData: unknown, htmlText: string): void { + if (!clipboardData || typeof clipboardData !== 'object' || !('setData' in clipboardData)) { + return; + } + + const clipboard = clipboardData as { setData: (mime: string, data: string) => void }; + clipboard.setData('text/html', htmlText); +}