This commit is contained in:
Zaki Ur Rehman 2026-05-13 03:01:40 +08:00 committed by GitHub
commit 9719db6cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 223 additions and 27 deletions

View file

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

View file

@ -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, '');
const markdownText = cleanedText;
const plainText = normalizeClipboardPlainText(transformMarkdownTablesToTSV(markdownText));
const htmlText = buildClipboardHTML(markdownText);
copy(cleanedText, { format: 'text/plain' });
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,135 @@ 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 {
return parseTableCells(row).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.replace(/\\\|/g, '|').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);
}