From a214a778ae52ca3ef67e42d10b0c6825be11be00 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 25 Sep 2025 16:24:22 -0600 Subject: [PATCH 1/2] Update handleTableCopy.ts --- .../ui/table/AppDataGrid/TanStackDataGrid.tsx | 7 +- .../AppDataGrid/helpers/handleTableCopy.ts | 132 ++++++++++++++---- 2 files changed, 110 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx index 390caa9ed..d84813416 100644 --- a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx +++ b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx @@ -153,12 +153,15 @@ export const AppDataGrid: React.FC = React.memo( // Handle clipboard copy events to preserve table structure useEffect(() => { + const container = parentRef.current; + if (!container) return; + const copyHandler = (event: ClipboardEvent) => { handleTableCopy(event, { table, parentRef }); }; - document.addEventListener('copy', copyHandler); - return () => document.removeEventListener('copy', copyHandler); + container.addEventListener('copy', copyHandler); + return () => container.removeEventListener('copy', copyHandler); }, [table]); return ( diff --git a/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts b/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts index ac895b094..999460d8e 100644 --- a/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts +++ b/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts @@ -1,4 +1,5 @@ import type { Table } from '@tanstack/react-table'; +import { CELL_HEIGHT } from '../constants'; export interface HandleTableCopyOptions { table: Table>; @@ -25,38 +26,115 @@ export function handleTableCopy(event: ClipboardEvent, options: HandleTableCopyO const selectedText = selection.toString().trim(); if (!selectedText) return; - // Build structured table data from the current table state + // Get the selected range + const range = selection.getRangeAt(0); + + // 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; + }, + }); + const visibleRows = table.getRowModel().rows; + const selectedTdElements: HTMLTableCellElement[] = []; + + let node = walker.nextNode(); + while (node) { + selectedTdElements.push(node as HTMLTableCellElement); + node = walker.nextNode(); + } + + // Map selected TD elements to their row and column data + for (const tdElement of selectedTdElements) { + // Find the row element (tr) that contains this td + const trElement = tdElement.closest('tr'); + if (!trElement) continue; + + // Find the row index by checking the transform translateY value + const style = trElement.getAttribute('style'); + const translateYMatch = style?.match(/translateY\((\d+(?:\.\d+)?)px\)/); + if (!translateYMatch) continue; + + const translateY = parseFloat(translateYMatch[1]); + const rowIndex = Math.floor(translateY / CELL_HEIGHT); + + const row = visibleRows[rowIndex]; + 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 columnIndex = tdElements.indexOf(tdElement); + + const visibleCells = row.getVisibleCells(); + const cell = visibleCells[columnIndex]; + + if (cell) { + const value = cell.getValue(); + const stringValue = value !== null && value !== undefined ? String(value) : ''; + + selectedCells.push({ + rowIndex, + columnId: cell.column.id, + value: stringValue, + }); + } + } + + // If no cells were found, fall back to plain text + if (selectedCells.length === 0) { + return; // Let the browser handle the default copy behavior + } + + // Group cells by row and sort by column order + const cellsByRow = new Map>(); 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') - ) + for (const cell of selectedCells) { + if (!cellsByRow.has(cell.rowIndex)) { + cellsByRow.set(cell.rowIndex, new Map()); + } + const rowMap = cellsByRow.get(cell.rowIndex); + if (rowMap) { + rowMap.set(cell.columnId, cell.value); + } + } + + // Build TSV data respecting column order + const sortedRowIndices = Array.from(cellsByRow.keys()).sort((a, b) => a - b); + const tsvData = sortedRowIndices + .map((rowIndex) => { + const rowCells = cellsByRow.get(rowIndex); + if (!rowCells) return ''; + + const selectedColumnIds = Array.from(rowCells.keys()); + + // Maintain column order for multi-column selections + const orderedColumns = columnOrder.filter((colId) => selectedColumnIds.includes(colId)); + + return orderedColumns.map((colId) => rowCells.get(colId) || '').join('\t'); + }) .join('\n'); - // Create HTML table structure - const htmlTable = `${visibleRows - .map( - (row) => - `${columnOrder - .map((colId) => { - const cell = row.getVisibleCells().find((cell) => cell.column.id === colId); - if (!cell) return ''; - const value = cell.getValue(); - const stringValue = value !== null && value !== undefined ? String(value) : ''; - return ``; - }) - .join('')}` - ) + // Build HTML table structure + const htmlTable = `
${stringValue}
${sortedRowIndices + .map((rowIndex) => { + const rowCells = cellsByRow.get(rowIndex); + if (!rowCells) return ''; + + const selectedColumnIds = Array.from(rowCells.keys()); + const orderedColumns = columnOrder.filter((colId) => selectedColumnIds.includes(colId)); + + return `${orderedColumns + .map((colId) => ``) + .join('')}`; + }) .join('')}
${rowCells.get(colId) || ''}
`; // Set clipboard data with both formats From 24e8071e7eff476aed8eee1e57cad5fbb51d035a Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 25 Sep 2025 16:30:05 -0600 Subject: [PATCH 2/2] handle copy on table endpoint --- .../ui/table/AppDataGrid/TanStackDataGrid.tsx | 16 +- .../helpers/handleTableCopy.test.ts | 302 +++++++++++++++++- .../AppDataGrid/helpers/handleTableCopy.ts | 156 ++++++++- 3 files changed, 441 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx index d84813416..02bad6fd4 100644 --- a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx +++ b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx @@ -152,17 +152,10 @@ export const AppDataGrid: React.FC = 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 (
@@ -176,6 +169,7 @@ export const AppDataGrid: React.FC = React.memo( 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 => ({ @@ -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', + '
John25
' + ); + }); }); diff --git a/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts b/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts index 999460d8e..0b14f2e90 100644 --- a/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts +++ b/apps/web/src/components/ui/table/AppDataGrid/helpers/handleTableCopy.ts @@ -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 = `${visibleRows + .map( + (row) => + `${columnOrder + .map((colId) => { + const cell = row.getVisibleCells().find((cell) => cell.column.id === colId); + if (!cell) return ''; + const value = cell.getValue(); + const stringValue = value !== null && value !== undefined ? String(value) : ''; + return ``; + }) + .join('')}` + ) + .join('')}
${stringValue}
`; + + // 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