mirror of https://github.com/buster-so/buster.git
Merge pull request #1159 from buster-so/big-nate-bus-1952-three-dot-open-in-new-tab-doesnt-do-anything-in-chat
Big nate bus 1952 three dot open in new tab doesnt do anything in chat
This commit is contained in:
commit
7de7267ddf
|
@ -152,14 +152,10 @@ export const AppDataGrid: React.FC<TanStackDataGridProps> = React.memo(
|
||||||
}, [onReady]);
|
}, [onReady]);
|
||||||
|
|
||||||
// Handle clipboard copy events to preserve table structure
|
// Handle clipboard copy events to preserve table structure
|
||||||
useEffect(() => {
|
const handleCopy = (event: React.ClipboardEvent) => {
|
||||||
const copyHandler = (event: ClipboardEvent) => {
|
// Convert React event to native ClipboardEvent for handleTableCopy
|
||||||
handleTableCopy(event, { table, parentRef });
|
handleTableCopy(event.nativeEvent, { table, parentRef });
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('copy', copyHandler);
|
|
||||||
return () => document.removeEventListener('copy', copyHandler);
|
|
||||||
}, [table]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className={cn('h-full w-full overflow-auto', className)} style={style}>
|
<div ref={parentRef} className={cn('h-full w-full overflow-auto', className)} style={style}>
|
||||||
|
@ -173,6 +169,7 @@ export const AppDataGrid: React.FC<TanStackDataGridProps> = React.memo(
|
||||||
<table
|
<table
|
||||||
className="bg-background w-full"
|
className="bg-background w-full"
|
||||||
style={{ borderCollapse: 'separate', borderSpacing: 0 }}
|
style={{ borderCollapse: 'separate', borderSpacing: 0 }}
|
||||||
|
onCopy={handleCopy}
|
||||||
>
|
>
|
||||||
<DataGridHeader
|
<DataGridHeader
|
||||||
table={table}
|
table={table}
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
import type { Cell, Column, Row, Table } from '@tanstack/react-table';
|
import type { Cell, Column, Row, Table } from '@tanstack/react-table';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CELL_HEIGHT } from '../constants';
|
||||||
import { type HandleTableCopyOptions, handleTableCopy } from './handleTableCopy';
|
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 = {
|
const mockSelection = {
|
||||||
rangeCount: 0,
|
rangeCount: 0,
|
||||||
anchorNode: null,
|
anchorNode: null as Node | null,
|
||||||
toString: vi.fn(),
|
toString: vi.fn(),
|
||||||
getRangeAt: vi.fn(),
|
getRangeAt: vi.fn(() => mockRange),
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.defineProperty(window, 'getSelection', {
|
Object.defineProperty(window, 'getSelection', {
|
||||||
|
@ -15,6 +23,16 @@ Object.defineProperty(window, 'getSelection', {
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock document.createTreeWalker
|
||||||
|
const mockTreeWalker = {
|
||||||
|
nextNode: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'createTreeWalker', {
|
||||||
|
value: vi.fn(() => mockTreeWalker),
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Create mock table data
|
// Create mock table data
|
||||||
const createMockCell = (value: any, columnId: string): Cell<any, any> =>
|
const createMockCell = (value: any, columnId: string): Cell<any, any> =>
|
||||||
({
|
({
|
||||||
|
@ -55,10 +73,19 @@ describe('handleTableCopy', () => {
|
||||||
preventDefault: vi.fn(),
|
preventDefault: vi.fn(),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// Reset selection mock
|
// Reset mocks with proper types and defaults
|
||||||
mockSelection.rangeCount = 1;
|
mockSelection.rangeCount = 1;
|
||||||
mockSelection.anchorNode = mockContainer as any;
|
mockSelection.anchorNode = mockContainer;
|
||||||
mockSelection.toString = vi.fn(() => 'selected text');
|
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', () => {
|
it('should handle basic table data with mixed types', () => {
|
||||||
|
@ -149,4 +176,269 @@ describe('handleTableCopy', () => {
|
||||||
expect(mockEvent.clipboardData!.setData).not.toHaveBeenCalled();
|
expect(mockEvent.clipboardData!.setData).not.toHaveBeenCalled();
|
||||||
expect(mockEvent.preventDefault).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>'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
|
import { CELL_HEIGHT } from '../constants';
|
||||||
|
|
||||||
export interface HandleTableCopyOptions {
|
export interface HandleTableCopyOptions {
|
||||||
table: Table<Record<string, string | number | Date | null>>;
|
table: Table<Record<string, string | number | Date | null>>;
|
||||||
|
@ -25,38 +26,237 @@ export function handleTableCopy(event: ClipboardEvent, options: HandleTableCopyO
|
||||||
const selectedText = selection.toString().trim();
|
const selectedText = selection.toString().trim();
|
||||||
if (!selectedText) return;
|
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 visibleRows = table.getRowModel().rows;
|
||||||
const columnOrder = table.getAllColumns().map((col) => col.id);
|
let selectedTdElements: HTMLTableCellElement[] = [];
|
||||||
|
|
||||||
// Create tab-separated values for the entire visible data
|
// If selection is clearly within a single cell, only include that cell
|
||||||
const tsvData = visibleRows
|
if (startTd && endTd && startTd === endTd) {
|
||||||
.map((row) =>
|
selectedTdElements = [startTd as HTMLTableCellElement];
|
||||||
columnOrder
|
} else {
|
||||||
.map((colId) => {
|
// Multi-cell selection or fallback - use tree walker to find all intersecting cells
|
||||||
const cell = row.getVisibleCells().find((cell) => cell.column.id === colId);
|
const walker = document.createTreeWalker(
|
||||||
if (!cell) return '';
|
range.commonAncestorContainer,
|
||||||
const value = cell.getValue();
|
NodeFilter.SHOW_ELEMENT,
|
||||||
return value !== null && value !== undefined ? String(value) : '';
|
{
|
||||||
})
|
acceptNode: (node) => {
|
||||||
.join('\t')
|
if (node.nodeName === 'TD' && range.intersectsNode(node)) {
|
||||||
)
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
.join('\n');
|
}
|
||||||
|
return NodeFilter.FILTER_SKIP;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Create HTML table structure
|
let node = walker.nextNode();
|
||||||
const htmlTable = `<table><tbody>${visibleRows
|
while (node) {
|
||||||
.map(
|
selectedTdElements.push(node as HTMLTableCellElement);
|
||||||
(row) =>
|
node = walker.nextNode();
|
||||||
`<tr>${columnOrder
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
.map((colId) => {
|
||||||
const cell = row.getVisibleCells().find((cell) => cell.column.id === colId);
|
const cell = row.getVisibleCells().find((cell) => cell.column.id === colId);
|
||||||
if (!cell) return '<td></td>';
|
if (!cell) return '';
|
||||||
const value = cell.getValue();
|
const value = cell.getValue();
|
||||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
return value !== null && value !== undefined ? String(value) : '';
|
||||||
return `<td>${stringValue}</td>`;
|
|
||||||
})
|
})
|
||||||
.join('')}</tr>`
|
.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
|
||||||
|
const cellsByRow = new Map<number, Map<string, string>>();
|
||||||
|
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 = `<table><tbody>${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 `<tr>${orderedColumns
|
||||||
|
.map((colId) => `<td>${rowCells.get(colId) || ''}</td>`)
|
||||||
|
.join('')}</tr>`;
|
||||||
|
})
|
||||||
.join('')}</tbody></table>`;
|
.join('')}</tbody></table>`;
|
||||||
|
|
||||||
// Set clipboard data with both formats
|
// Set clipboard data with both formats
|
||||||
|
|
Loading…
Reference in New Issue