Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 71 additions & 24 deletions client/src/hooks/Messages/useCopyToClipboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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' }));
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' }));
});
});

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' }));
});
});

Expand Down Expand Up @@ -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' }));
});
});

Expand Down Expand Up @@ -480,15 +527,15 @@ 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
[2] https://example.com/2
[3] https://example.com/3
`;

expect(mockCopy).toHaveBeenCalledWith(expectedText, { format: 'text/plain' });
expect(mockCopy).toHaveBeenCalledWith(expectedText, expect.objectContaining({ format: 'text/plain' }));
});
});
});
160 changes: 156 additions & 4 deletions client/src/hooks/Messages/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 = `<thead><tr>${headers.map((cell) => `<th>${inlineMarkdownToHTML(cell)}</th>`).join('')}</tr></thead>`;
const tableBody = `<tbody>${rows
.map((row) => `<tr>${row.map((cell) => `<td>${inlineMarkdownToHTML(cell)}</td>`).join('')}</tr>`)
.join('')}</tbody>`;
htmlParts.push(`<table>${tableHeader}${tableBody}</table>`);
continue;
}

if (line.trim().length === 0) {
htmlParts.push('<br />');
} else {
htmlParts.push(`<p>${inlineMarkdownToHTML(line)}</p>`);
}
index += 1;
}

return `<div>${htmlParts.join('')}</div>`;
}

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, '<strong>$1</strong>');
}

function escapeHTML(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

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