diff --git a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx index 390caa9ed..02bad6fd4 100644 --- a/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx +++ b/apps/web/src/components/ui/table/AppDataGrid/TanStackDataGrid.tsx @@ -152,14 +152,10 @@ export const AppDataGrid: React.FC = React.memo( }, [onReady]); // Handle clipboard copy events to preserve table structure - useEffect(() => { - const copyHandler = (event: ClipboardEvent) => { - handleTableCopy(event, { table, parentRef }); - }; - - document.addEventListener('copy', copyHandler); - return () => document.removeEventListener('copy', copyHandler); - }, [table]); + const handleCopy = (event: React.ClipboardEvent) => { + // Convert React event to native ClipboardEvent for handleTableCopy + handleTableCopy(event.nativeEvent, { table, parentRef }); + }; return (
@@ -173,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 ac895b094..0b14f2e90 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,237 @@ 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 }[] = []; + + // 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 columnOrder = table.getAllColumns().map((col) => col.id); + let selectedTdElements: HTMLTableCellElement[] = []; - // 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'); + // 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; + }, + } + ); - // Create HTML table structure - const htmlTable = `${visibleRows - .map( - (row) => - `${columnOrder + 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 + 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.children).filter((child) => child.tagName === '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, + }); + } + } + + // 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) { + 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 ''; + if (!cell) return ''; const value = cell.getValue(); - const stringValue = value !== null && value !== undefined ? String(value) : ''; - return ``; + return value !== null && value !== undefined ? String(value) : ''; }) - .join('')}` - ) + .join('\t') + ) + .join('\n'); + + // Create HTML table structure + const htmlTable = `
${stringValue}
${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 + const cellsByRow = new Map>(); + const columnOrder = table.getAllColumns().map((col) => col.id); + + 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'); + + // Build HTML table structure + const htmlTable = `${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