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:
Nate Kelley 2025-09-25 16:50:48 -06:00 committed by GitHub
commit 7de7267ddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 527 additions and 38 deletions

View File

@ -152,14 +152,10 @@ export const AppDataGrid: React.FC<TanStackDataGridProps> = 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 (
<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
className="bg-background w-full"
style={{ borderCollapse: 'separate', borderSpacing: 0 }}
onCopy={handleCopy}
>
<DataGridHeader
table={table}

View File

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

View File

@ -1,4 +1,5 @@
import type { Table } from '@tanstack/react-table';
import { CELL_HEIGHT } from '../constants';
export interface HandleTableCopyOptions {
table: Table<Record<string, string | number | Date | null>>;
@ -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 = `<table><tbody>${visibleRows
.map(
(row) =>
`<tr>${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 '<td></td>';
if (!cell) return '';
const value = cell.getValue();
const stringValue = value !== null && value !== undefined ? String(value) : '';
return `<td>${stringValue}</td>`;
return value !== null && value !== undefined ? String(value) : '';
})
.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>`;
// Set clipboard data with both formats