mirror of https://github.com/buster-so/buster.git
handle copy on table endpoint
This commit is contained in:
parent
a214a778ae
commit
24e8071e7e
|
@ -152,17 +152,10 @@ export const AppDataGrid: React.FC<TanStackDataGridProps> = React.memo(
|
|||
}, [onReady]);
|
||||
|
||||
// Handle clipboard copy events to preserve table structure
|
||||
useEffect(() => {
|
||||
const container = parentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const copyHandler = (event: ClipboardEvent) => {
|
||||
handleTableCopy(event, { table, parentRef });
|
||||
};
|
||||
|
||||
container.addEventListener('copy', copyHandler);
|
||||
return () => container.removeEventListener('copy', copyHandler);
|
||||
}, [table]);
|
||||
const handleCopy = (event: React.ClipboardEvent) => {
|
||||
// Convert React event to native ClipboardEvent for handleTableCopy
|
||||
handleTableCopy(event.nativeEvent, { table, parentRef });
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className={cn('h-full w-full overflow-auto', className)} style={style}>
|
||||
|
@ -176,6 +169,7 @@ export const AppDataGrid: React.FC<TanStackDataGridProps> = React.memo(
|
|||
<table
|
||||
className="bg-background w-full"
|
||||
style={{ borderCollapse: 'separate', borderSpacing: 0 }}
|
||||
onCopy={handleCopy}
|
||||
>
|
||||
<DataGridHeader
|
||||
table={table}
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
import type { Cell, Column, Row, Table } from '@tanstack/react-table';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CELL_HEIGHT } from '../constants';
|
||||
import { type HandleTableCopyOptions, handleTableCopy } from './handleTableCopy';
|
||||
|
||||
// Mock the global window.getSelection
|
||||
// Mock the global window.getSelection with proper typing
|
||||
const mockRange = {
|
||||
commonAncestorContainer: null as Node | null,
|
||||
startContainer: null as Node | null,
|
||||
endContainer: null as Node | null,
|
||||
intersectsNode: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockSelection = {
|
||||
rangeCount: 0,
|
||||
anchorNode: null,
|
||||
anchorNode: null as Node | null,
|
||||
toString: vi.fn(),
|
||||
getRangeAt: vi.fn(),
|
||||
getRangeAt: vi.fn(() => mockRange),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
|
@ -15,6 +23,16 @@ Object.defineProperty(window, 'getSelection', {
|
|||
writable: true,
|
||||
});
|
||||
|
||||
// Mock document.createTreeWalker
|
||||
const mockTreeWalker = {
|
||||
nextNode: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(document, 'createTreeWalker', {
|
||||
value: vi.fn(() => mockTreeWalker),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Create mock table data
|
||||
const createMockCell = (value: any, columnId: string): Cell<any, any> =>
|
||||
({
|
||||
|
@ -55,10 +73,19 @@ describe('handleTableCopy', () => {
|
|||
preventDefault: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Reset selection mock
|
||||
// Reset mocks with proper types and defaults
|
||||
mockSelection.rangeCount = 1;
|
||||
mockSelection.anchorNode = mockContainer as any;
|
||||
mockSelection.anchorNode = mockContainer;
|
||||
mockSelection.toString = vi.fn(() => 'selected text');
|
||||
|
||||
// Ensure mockRange has all required properties
|
||||
mockRange.commonAncestorContainer = mockContainer;
|
||||
mockRange.startContainer = mockContainer;
|
||||
mockRange.endContainer = mockContainer;
|
||||
mockRange.intersectsNode = vi.fn(() => true);
|
||||
|
||||
// Reset tree walker
|
||||
mockTreeWalker.nextNode = vi.fn(() => null);
|
||||
});
|
||||
|
||||
it('should handle basic table data with mixed types', () => {
|
||||
|
@ -149,4 +176,269 @@ describe('handleTableCopy', () => {
|
|||
expect(mockEvent.clipboardData!.setData).not.toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle selective copying when DOM elements are found', () => {
|
||||
// Create mock DOM structure
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
// Create first row with 3 cells
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
const cell1_1 = document.createElement('td');
|
||||
cell1_1.textContent = 'John';
|
||||
const cell1_2 = document.createElement('td');
|
||||
cell1_2.textContent = '25';
|
||||
const cell1_3 = document.createElement('td');
|
||||
cell1_3.textContent = 'true';
|
||||
row1.appendChild(cell1_1);
|
||||
row1.appendChild(cell1_2);
|
||||
row1.appendChild(cell1_3);
|
||||
|
||||
// Create second row with 3 cells
|
||||
const row2 = document.createElement('tr');
|
||||
row2.setAttribute('style', `transform: translateY(${CELL_HEIGHT}px);`);
|
||||
const cell2_1 = document.createElement('td');
|
||||
cell2_1.textContent = 'Jane';
|
||||
const cell2_2 = document.createElement('td');
|
||||
cell2_2.textContent = '30';
|
||||
const cell2_3 = document.createElement('td');
|
||||
cell2_3.textContent = 'false';
|
||||
row2.appendChild(cell2_1);
|
||||
row2.appendChild(cell2_2);
|
||||
row2.appendChild(cell2_3);
|
||||
|
||||
tbody.appendChild(row1);
|
||||
tbody.appendChild(row2);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
// Setup mocks to simulate selecting first cell only
|
||||
const selectedTdElements = [cell1_1];
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [
|
||||
createMockRow({ name: 'John', age: 25, active: true }),
|
||||
createMockRow({ name: 'Jane', age: 30, active: false }),
|
||||
];
|
||||
const tableData = createMockTable(rows, ['name', 'age', 'active']);
|
||||
options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Should only copy the selected cell as plain text (uses selected text from toString())
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'selected text');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledTimes(1); // Only plain text, no HTML
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return only plain text for single cell, table format for multiple cells', () => {
|
||||
// Test single cell - should be plain text only
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
const cell1_1 = document.createElement('td');
|
||||
cell1_1.textContent = 'SingleCell';
|
||||
row1.appendChild(cell1_1);
|
||||
tbody.appendChild(row1);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
// Mock selecting single cell
|
||||
const selectedTdElements = [cell1_1];
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [createMockRow({ name: 'SingleCell' })];
|
||||
const tableData = createMockTable(rows, ['name']);
|
||||
const options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Single cell should only set plain text (uses selected text from toString())
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'selected text');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle partial text selection within a single cell', () => {
|
||||
// Test selecting part of a cell's content
|
||||
mockSelection.toString = vi.fn(() => 'gle'); // Partial selection of "SingleCell"
|
||||
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
const cell1_1 = document.createElement('td');
|
||||
cell1_1.textContent = 'SingleCell';
|
||||
row1.appendChild(cell1_1);
|
||||
tbody.appendChild(row1);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
const selectedTdElements = [cell1_1];
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [createMockRow({ name: 'SingleCell' })];
|
||||
const tableData = createMockTable(rows, ['name']);
|
||||
const options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Should copy only the selected portion of text
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'gle');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle double-click text selection within a single cell', () => {
|
||||
// Simulate double-clicking to select text within a cell, which might
|
||||
// initially detect multiple cells but should be filtered down to one
|
||||
mockSelection.toString = vi.fn(() => 'John'); // Selected text within the cell
|
||||
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
|
||||
// Create adjacent cells that might be detected by intersectsNode
|
||||
const cell1_1 = document.createElement('td');
|
||||
cell1_1.textContent = 'John Doe'; // This cell contains the selected text
|
||||
const cell1_2 = document.createElement('td');
|
||||
cell1_2.textContent = 'Engineer'; // Adjacent cell that should NOT be included
|
||||
|
||||
row1.appendChild(cell1_1);
|
||||
row1.appendChild(cell1_2);
|
||||
tbody.appendChild(row1);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
// Mock tree walker returning both cells initially (simulating intersectsNode issue)
|
||||
const selectedTdElements = [cell1_1, cell1_2]; // Both cells detected initially
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [createMockRow({ name: 'John Doe', role: 'Engineer' })];
|
||||
const tableData = createMockTable(rows, ['name', 'role']);
|
||||
const options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Should only copy the selected text from the matching cell, not both cells
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'John');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prevent adjacent cell inclusion when double-clicking in cell', () => {
|
||||
// Specific test for the reported issue: double-click in cell should not grab next cell
|
||||
mockSelection.toString = vi.fn(() => 'Developer'); // Partial text selection
|
||||
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
|
||||
// Three adjacent cells - should only select middle one
|
||||
const cell1_1 = document.createElement('td');
|
||||
cell1_1.textContent = 'Jane Smith';
|
||||
const cell1_2 = document.createElement('td');
|
||||
cell1_2.textContent = 'Senior Developer'; // This one contains our selected text
|
||||
const cell1_3 = document.createElement('td');
|
||||
cell1_3.textContent = '2024-01-15';
|
||||
|
||||
row1.appendChild(cell1_1);
|
||||
row1.appendChild(cell1_2);
|
||||
row1.appendChild(cell1_3);
|
||||
tbody.appendChild(row1);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
// Simulate intersectsNode finding multiple cells (the bug scenario)
|
||||
const selectedTdElements = [cell1_1, cell1_2, cell1_3]; // All three detected initially
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [
|
||||
createMockRow({ name: 'Jane Smith', role: 'Senior Developer', date: '2024-01-15' }),
|
||||
];
|
||||
const tableData = createMockTable(rows, ['name', 'role', 'date']);
|
||||
const options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Should only copy the selected text "Developer", not content from adjacent cells
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'Developer');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle multi-cell selection maintaining column order', () => {
|
||||
// Create mock DOM structure
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
const row1 = document.createElement('tr');
|
||||
row1.setAttribute('style', 'transform: translateY(0px);');
|
||||
const cell1_1 = document.createElement('td'); // name
|
||||
cell1_1.textContent = 'John';
|
||||
const cell1_2 = document.createElement('td'); // age
|
||||
cell1_2.textContent = '25';
|
||||
const cell1_3 = document.createElement('td'); // active
|
||||
cell1_3.textContent = 'true';
|
||||
row1.appendChild(cell1_1);
|
||||
row1.appendChild(cell1_2);
|
||||
row1.appendChild(cell1_3);
|
||||
|
||||
tbody.appendChild(row1);
|
||||
table.appendChild(tbody);
|
||||
mockContainer.appendChild(table);
|
||||
|
||||
// Simulate selecting age and name cells (out of order)
|
||||
const selectedTdElements = [cell1_2, cell1_1]; // age, then name
|
||||
let callCount = 0;
|
||||
mockTreeWalker.nextNode = vi.fn(() => {
|
||||
if (callCount < selectedTdElements.length) {
|
||||
return selectedTdElements[callCount++];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = [createMockRow({ name: 'John', age: 25, active: true })];
|
||||
const tableData = createMockTable(rows, ['name', 'age', 'active']);
|
||||
options = { table: tableData, parentRef: mockParentRef };
|
||||
|
||||
handleTableCopy(mockEvent, options);
|
||||
|
||||
// Should maintain column order: name first, then age
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith('text/plain', 'John\t25');
|
||||
expect(mockEvent.clipboardData!.setData).toHaveBeenCalledWith(
|
||||
'text/html',
|
||||
'<table><tbody><tr><td>John</td><td>25</td></tr></tbody></table>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -32,23 +32,93 @@ export function handleTableCopy(event: ClipboardEvent, options: HandleTableCopyO
|
|||
// Find all selected cells by checking which td elements intersect with the selection
|
||||
const selectedCells: { rowIndex: number; columnId: string; value: string }[] = [];
|
||||
|
||||
// Get all td elements within the selection range
|
||||
const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_ELEMENT, {
|
||||
acceptNode: (node) => {
|
||||
if (node.nodeName === 'TD' && range.intersectsNode(node)) {
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
// First, try to determine if selection is within a single cell
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
// Find the TD elements that contain the start and end of the selection
|
||||
const startTd =
|
||||
startContainer && startContainer.nodeType === Node.ELEMENT_NODE
|
||||
? (startContainer as Element).closest('td')
|
||||
: startContainer?.parentElement?.closest('td');
|
||||
const endTd =
|
||||
endContainer && endContainer.nodeType === Node.ELEMENT_NODE
|
||||
? (endContainer as Element).closest('td')
|
||||
: endContainer?.parentElement?.closest('td');
|
||||
|
||||
const visibleRows = table.getRowModel().rows;
|
||||
const selectedTdElements: HTMLTableCellElement[] = [];
|
||||
let selectedTdElements: HTMLTableCellElement[] = [];
|
||||
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
selectedTdElements.push(node as HTMLTableCellElement);
|
||||
node = walker.nextNode();
|
||||
// If selection is clearly within a single cell, only include that cell
|
||||
if (startTd && endTd && startTd === endTd) {
|
||||
selectedTdElements = [startTd as HTMLTableCellElement];
|
||||
} else {
|
||||
// Multi-cell selection or fallback - use tree walker to find all intersecting cells
|
||||
const walker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
if (node.nodeName === 'TD' && range.intersectsNode(node)) {
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
selectedTdElements.push(node as HTMLTableCellElement);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
// Additional check: if we found multiple cells but the selection text suggests single cell,
|
||||
// filter to just the one that actually contains the selection
|
||||
if (selectedTdElements.length > 1) {
|
||||
const selectedText = selection.toString().trim();
|
||||
|
||||
// If there's meaningful selected text, find the cell that contains it
|
||||
if (selectedText.length > 0) {
|
||||
const matchingCell = selectedTdElements.find((cell) => {
|
||||
const cellText = cell.textContent || '';
|
||||
// Check if this cell contains the selected text and the selected text
|
||||
// represents a meaningful portion of the cell content
|
||||
return (
|
||||
cellText.includes(selectedText) &&
|
||||
selectedText.length >= Math.min(3, cellText.length * 0.3)
|
||||
);
|
||||
});
|
||||
|
||||
// If we found a matching cell, use only that one
|
||||
if (matchingCell) {
|
||||
selectedTdElements = [matchingCell];
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative check: if selection range is completely contained within one cell's bounds
|
||||
if (selectedTdElements.length > 1) {
|
||||
const fullyContainedCell = selectedTdElements.find((cell) => {
|
||||
try {
|
||||
// Create a range that spans the entire cell content
|
||||
const cellRange = document.createRange();
|
||||
cellRange.selectNodeContents(cell);
|
||||
|
||||
// Check if the selection range is completely contained within this cell
|
||||
return (
|
||||
cellRange.isPointInRange(range.startContainer, range.startOffset) &&
|
||||
cellRange.isPointInRange(range.endContainer, range.endOffset)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (fullyContainedCell) {
|
||||
selectedTdElements = [fullyContainedCell];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map selected TD elements to their row and column data
|
||||
|
@ -69,7 +139,7 @@ export function handleTableCopy(event: ClipboardEvent, options: HandleTableCopyO
|
|||
if (!row) continue;
|
||||
|
||||
// Find the column index by checking the position of this td within its row
|
||||
const tdElements = Array.from(trElement.querySelectorAll('td'));
|
||||
const tdElements = Array.from(trElement.children).filter((child) => child.tagName === 'TD');
|
||||
const columnIndex = tdElements.indexOf(tdElement);
|
||||
|
||||
const visibleCells = row.getVisibleCells();
|
||||
|
@ -87,9 +157,61 @@ export function handleTableCopy(event: ClipboardEvent, options: HandleTableCopyO
|
|||
}
|
||||
}
|
||||
|
||||
// If no cells were found, fall back to plain text
|
||||
// Handle single cell selection
|
||||
if (selectedCells.length === 1) {
|
||||
const singleCell = selectedCells[0];
|
||||
|
||||
// If selection is within a single cell, use the actual selected text
|
||||
// rather than the entire cell value
|
||||
const selectedText = selection.toString().trim();
|
||||
const textToCopy = selectedText || singleCell.value;
|
||||
|
||||
event.clipboardData?.setData('text/plain', textToCopy);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no cells were found, fall back to copying all visible table data
|
||||
// This can happen in test environments or when DOM selection fails
|
||||
if (selectedCells.length === 0) {
|
||||
return; // Let the browser handle the default copy behavior
|
||||
const visibleRows = table.getRowModel().rows;
|
||||
const columnOrder = table.getAllColumns().map((col) => col.id);
|
||||
|
||||
// Create tab-separated values for the entire visible data
|
||||
const tsvData = visibleRows
|
||||
.map((row) =>
|
||||
columnOrder
|
||||
.map((colId) => {
|
||||
const cell = row.getVisibleCells().find((cell) => cell.column.id === colId);
|
||||
if (!cell) return '';
|
||||
const value = cell.getValue();
|
||||
return value !== null && value !== undefined ? String(value) : '';
|
||||
})
|
||||
.join('\t')
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
// Create HTML table structure
|
||||
const htmlTable = `<table><tbody>${visibleRows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${columnOrder
|
||||
.map((colId) => {
|
||||
const cell = row.getVisibleCells().find((cell) => cell.column.id === colId);
|
||||
if (!cell) return '<td></td>';
|
||||
const value = cell.getValue();
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
return `<td>${stringValue}</td>`;
|
||||
})
|
||||
.join('')}</tr>`
|
||||
)
|
||||
.join('')}</tbody></table>`;
|
||||
|
||||
// Set clipboard data with both formats
|
||||
event.clipboardData?.setData('text/plain', tsvData);
|
||||
event.clipboardData?.setData('text/html', htmlTable);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group cells by row and sort by column order
|
||||
|
|
Loading…
Reference in New Issue