diff --git a/README.md b/README.md index 217a5e4..7cb3b10 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Cursor (built on VS Code 1.99) and the latest VS Code releases (1.102). - **Edit Modes:** - Quick edit: start typing any character to edit the selected cell immediately. Press any Arrow key to save and move the selection to the next cell in that direction. - Detail edit: press `Enter` on a selected cell or double‑click to enter a focused edit. Arrow Left/Right move the text caret; Arrow Up goes to start; Arrow Down goes to end. Click outside the cell (or blur) to save. -- **Keyboard Navigation:** Use Arrow keys to move between cells when not editing. Use `Tab`/`Shift+Tab` to move horizontally (wrapping across rows as needed). -- **Multi-Cell Selection:** Click and drag or use `Shift + Click` to select multiple cells, then copy them as CSV using `Ctrl/Cmd + C`. -- **Find & Replace:** Press `Ctrl/Cmd + F` to open Find, or `Ctrl/Cmd + H` to open Find + Replace. +- **Keyboard Navigation:** Use Arrow keys to move between cells when not editing. Use `Tab`/`Shift+Tab` to move horizontally (wrapping across rows as needed). Holding an arrow keeps the active cell in view; with `Shift`, the viewport follows the moving end of the selection. +- **Multi-Cell Selection:** Cells show their selected color as soon as you press the mouse button. Drag or use `Shift + Click` to select multiple cells, then copy them as CSV using `Ctrl/Cmd + C`. Click a header once to select its column; a second separate click on the same header selects only that header cell. Double-click edits the header. Drag an already selected header to reorder its column or selected column group. +- **Find & Replace:** Press `Ctrl/Cmd + F` to open Find, or `Ctrl/Cmd + H` to open Find + Replace. Add `Shift` to use VS Code’s native Find/Replace in Files; shortcuts with `Alt` also stay outside the table overlay. --- @@ -144,6 +144,7 @@ Per-file (stored by the extension; set via commands): - Replace: `Ctrl/Cmd + H` - Next/Previous match: `F3` / `Shift + F3` (also `Enter` / `Shift + Enter` in the Find box) - Select all: `Ctrl/Cmd + A` + - Jump to an edge: `Ctrl/Cmd + Arrow`. Add `Shift` to extend the current selection from its fixed starting cell; whole-row and whole-column selections retain their shape. --- diff --git a/images/held-arrow-scroll.jpg b/images/held-arrow-scroll.jpg new file mode 100644 index 0000000..053e266 Binary files /dev/null and b/images/held-arrow-scroll.jpg differ diff --git a/images/mouse-selection.jpg b/images/mouse-selection.jpg new file mode 100644 index 0000000..6ee8a68 Binary files /dev/null and b/images/mouse-selection.jpg differ diff --git a/images/range-selection.jpg b/images/range-selection.jpg new file mode 100644 index 0000000..a89e9e6 Binary files /dev/null and b/images/range-selection.jpg differ diff --git a/media/main.js b/media/main.js index 0fc03be..8aaf634 100644 --- a/media/main.js +++ b/media/main.js @@ -150,6 +150,72 @@ const persistState = () => { } catch {} }; +// Coalesce repeated navigation to the latest cell before paint. Native smooth +// scrolling restarts on every key repeat and can leave selection offscreen. +let pendingCellReveal = null; +let cellRevealScheduled = false; +const scheduleCellReveal = (cell, center = false) => { + pendingCellReveal = { cell, center }; + if (cellRevealScheduled) return; + cellRevealScheduled = true; + requestAnimationFrame(() => { + cellRevealScheduled = false; + const request = pendingCellReveal; + pendingCellReveal = null; + if (!request?.cell?.isConnected || !scrollContainer) return; + const width = scrollContainer.clientWidth; + const height = scrollContainer.clientHeight; + if (width <= 0 || height <= 0) return; + const viewport = scrollContainer.getBoundingClientRect(); + const viewportLeft = viewport.left + scrollContainer.clientLeft; + const viewportTop = viewport.top + scrollContainer.clientTop; + let left = Math.max(0, viewportLeft); + let top = Math.max(0, viewportTop); + const right = Math.min(window.innerWidth, viewportLeft + width); + const bottom = Math.min(window.innerHeight, viewportTop + height); + const target = request.cell; + let fixedTop = target.classList.contains('csv-frozen-row'); + const fixedLeft = target.classList.contains('csv-frozen-column'); + + const filterBar = document.getElementById('filterStatus'); + if (filterBar) top = Math.max(top, filterBar.getBoundingClientRect().bottom); + const headerCell = table.tHead?.rows[0]?.cells[0]; + if (headerCell) { + const style = getComputedStyle(headerCell); + if (style.position === 'sticky' && style.top && style.top !== 'auto') { + top = Math.max(top, headerCell.getBoundingClientRect().bottom); + fixedTop = fixedTop || table.tHead.contains(target); + } + } + // Frozen panes are leading rows/columns. Read one representative per + // boundary, rather than scanning every rendered cell on each repeat. + for (const row of table.rows) { + const first = row.cells[0]; + if (!first?.classList.contains('csv-frozen-row')) break; + top = Math.max(top, first.getBoundingClientRect().bottom); + } + for (const cell of table.rows[0]?.cells || []) { + if (!cell.classList.contains('csv-frozen-column')) break; + left = Math.max(left, cell.getBoundingClientRect().right); + } + + const rect = target.getBoundingClientRect(); + const revealDelta = (start, end, min, max) => { + if (max <= min) return 0; + // A cell larger than the available pane cannot fit; keep its leading + // edge usable instead of alternating between opposite edges. + if (end - start > max - min) return start - min; + if (request.center) return (start + end - min - max) / 2; + return start < min ? start - min : end > max ? end - max : 0; + }; + const x = fixedLeft ? 0 : revealDelta(rect.left, rect.right, left, right); + const y = fixedTop ? 0 : revealDelta(rect.top, rect.bottom, top, bottom); + // Even zero delta cancels any native animation already in flight. + scrollContainer.scrollTo({ left: scrollContainer.scrollLeft + x, top: scrollContainer.scrollTop + y, behavior: 'instant' }); + persistState(); + }); +}; + const restoreState = () => { try { const st = vscode.getState() || {}; @@ -687,7 +753,8 @@ const startReorderDrag = (target, e) => { startX: e.clientX, startY: e.clientY, active: false, - beforeIndex: null + beforeIndex: null, + headerOnlyOnClick: e.detail === 1 && selectionMode === 'column' && anchorCell === target && selected.length === 1 ? target : null }; return true; } @@ -742,7 +809,7 @@ const onGlobalDragMove = e => { showRowDropIndicator(target.indicatorY); } }; -const onGlobalDragEnd = () => { +const onGlobalDragEnd = e => { if (resizeState) { resizeState = null; table.style.cursor = ''; @@ -750,12 +817,22 @@ const onGlobalDragEnd = () => { } if (!reorderState) return; - const { axis, indices, active, beforeIndex } = reorderState; + const { axis, indices, active, beforeIndex, headerOnlyOnClick } = reorderState; reorderState = null; hideDragIndicator(); table.style.cursor = ''; - if (!active || !Number.isFinite(beforeIndex)) return; + if (!active) { + // Keep selected columns available for dragging until an ordinary click ends. + // A real double-click has detail 2 and keeps the existing edit gesture. + if (headerOnlyOnClick && e.button === 0 && getCellTarget(e.target) === headerOnlyOnClick) { + selectionMode = 'cell'; + startCell = endCell = headerOnlyOnClick; + setSingleSelection(headerOnlyOnClick); + } + return; + } + if (!Number.isFinite(beforeIndex)) return; if (axis === 'column') { vscode.postMessage({ type: 'reorderColumns', indices, beforeIndex }); } else { @@ -903,6 +980,16 @@ table.addEventListener('mousedown', e => { selectionMode = (target.tagName === 'TH') ? "column" : (target.getAttribute('data-col') === '-1' ? "row" : "cell"); startCell = target; endCell = target; rangeEndCell = target; isSelecting = true; e.preventDefault(); + if (selectionMode === "column") { + const col = Number(target.getAttribute('data-col')); + selectFullColumnRange(col, col); + } else if (selectionMode === "row") { + const row = Number(target.getAttribute('data-row')); + selectFullRowRange(row, row); + } else { + const coords = getCellCoords(target); + selectRange(coords, coords); + } target.focus(); }); @@ -964,11 +1051,6 @@ table.addEventListener('mouseup', e => { if(!isSelecting) return; isSelecting = false; if(selectionMode === "cell"){ - if(startCell === endCell){ - clearSelection(); - startCell.classList.add('selected'); - currentSelection.push(startCell); - } anchorCell = startCell; rangeEndCell = endCell; persistState(); @@ -1022,6 +1104,19 @@ const selectFullRowRange = (row1, row2) => { }); }; +const extendSelectionTo = target => { + rangeEndCell = target; + const start = getCellCoords(anchorCell); + const end = getCellCoords(target); + if (isColumnHeaderCell(anchorCell)) { + selectFullColumnRange(start.col, end.col); + } else if (isRowIndexCell(anchorCell)) { + selectFullRowRange(start.row, end.row); + } else { + selectRange(start, end); + } +}; + const getDataCellCoords = cell => { if (!cell || typeof cell.getAttribute !== 'function') return null; const coords = getCellCoords(cell); @@ -1213,7 +1308,7 @@ const setActiveFindMatch = (index, shouldScroll = true) => { cell.classList.add('highlight'); cell.classList.add('active-match'); if (shouldScroll) { - cell.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + scheduleCellReveal(cell, true); } } updateFindStatus(); @@ -1467,9 +1562,9 @@ window.addEventListener('csvChunkLoaded', () => { } }); -// Capture-phase handler to intercept Cmd/Ctrl + Arrow and move to extremes +// Handle modified arrow jumps once; Shift extends the existing selection. document.addEventListener('keydown', e => { - if (isFindWidgetTarget(e.target)) { + if (isFindWidgetTarget(e.target) || isTextInputTarget(e.target)) { return; } const isArrowKey = (k) => ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Up','Down','Left','Right','Home','End'].includes(k); @@ -1478,38 +1573,26 @@ document.addEventListener('keydown', e => { e.stopImmediatePropagation(); e.stopPropagation(); - const sc = document.querySelector('.table-container'); - if (sc) { - if (['ArrowLeft','Left','Home'].includes(e.key)) sc.scrollTo({ left: 0, behavior: 'smooth' }); - if (['ArrowRight','Right','End'].includes(e.key)) sc.scrollTo({ left: sc.scrollWidth, behavior: 'smooth' }); - if (['ArrowUp','Up'].includes(e.key)) sc.scrollTo({ top: 0, behavior: 'smooth' }); - if (['ArrowDown','Down'].includes(e.key)) sc.scrollTo({ top: sc.scrollHeight, behavior: 'smooth' }); - } else { - if (['ArrowUp','Up'].includes(e.key)) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - if (['ArrowDown','Down'].includes(e.key)) { - const h = Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight - ); - window.scrollTo({ top: h, behavior: 'smooth' }); - } + // Select All has no fixed cell anchor; preserve the complete selection. + if (e.shiftKey && !anchorCell && currentSelection.length > 1) { + return; } - const ref = anchorCell || currentSelection[0] || document.querySelector('td.selected, th.selected') || document.querySelector('td, th'); + const fixedAnchor = anchorCell || currentSelection.find(cell => { + const { row, col } = getCellCoords(cell); + return Number.isInteger(row) && Number.isInteger(col); + }) || table.querySelector('td[data-row][data-col], th[data-row][data-col]'); + const ref = e.shiftKey && anchorCell ? (rangeEndCell || fixedAnchor) : fixedAnchor; let target = null; if (ref) { const { row, col } = getCellCoords(ref); if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) + const rowCells = Array.from(table.querySelectorAll('td[data-row="'+row+'"], th[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || ref; } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) + const rowCells = Array.from(table.querySelectorAll('td[data-row="'+row+'"], th[data-row="'+row+'"]')) .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === max) || ref; @@ -1523,24 +1606,19 @@ document.addEventListener('keydown', e => { } if (target) { - clearSelection(); - target.classList.add('selected'); - currentSelection.push(target); - anchorCell = target; - rangeEndCell = target; - persistState(); - target.focus({preventScroll:true}); - if (['ArrowUp','Up'].includes(e.key)) { - const topRow = getFirstDataRow(); - const below = table.querySelector('td[data-row="'+topRow+'"][data-col="'+getCellCoords(target).col+'"]'); - if (below) { - below.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } else { - target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } + if (e.shiftKey && fixedAnchor) { + anchorCell = fixedAnchor; + extendSelectionTo(target); } else { - target.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + clearSelection(); + target.classList.add('selected'); + currentSelection.push(target); + anchorCell = target; + rangeEndCell = target; } + persistState(); + (e.shiftKey ? anchorCell : target).focus({preventScroll:true}); + scheduleCellReveal(target); } } }, true); @@ -1550,12 +1628,12 @@ document.addEventListener('keydown', e => { return; } const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; - if ((e.ctrlKey || e.metaKey) && key === 'f') { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && key === 'f') { e.preventDefault(); openFindReplace(false); return; } - if ((e.ctrlKey || e.metaKey) && key === 'h') { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && key === 'h') { e.preventDefault(); openFindReplace(true); return; @@ -1602,61 +1680,6 @@ document.addEventListener('keydown', e => { return; } - const isArrowKey = (k) => ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Up','Down','Left','Right','Home','End'].includes(k); - if (!editingCell && (e.ctrlKey || e.metaKey) && isArrowKey(e.key)) { - e.preventDefault(); - const sc = document.querySelector('.table-container'); - if (sc) { - if (['ArrowLeft','Left','Home'].includes(e.key)) sc.scrollTo({ left: 0, behavior: 'smooth' }); - if (['ArrowRight','Right','End'].includes(e.key)) sc.scrollTo({ left: sc.scrollWidth, behavior: 'smooth' }); - if (['ArrowUp','Up'].includes(e.key)) sc.scrollTo({ top: 0, behavior: 'smooth' }); - if (['ArrowDown','Down'].includes(e.key)) sc.scrollTo({ top: sc.scrollHeight, behavior: 'smooth' }); - } - - let refCell = anchorCell; - if (!refCell) { - refCell = currentSelection[0] || document.querySelector('td.selected, th.selected'); - } - let target = null; - if (refCell) { - const { row, col } = getCellCoords(refCell); - if (['ArrowLeft','Left','Home'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) - .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); - const min = rowCells.reduce((acc, el) => Math.min(acc, parseInt(el.getAttribute('data-col'))), Infinity); - target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === min) || refCell; - } else if (['ArrowRight','Right','End'].includes(e.key)) { - const tag = (hasHeader && row === 0 ? 'th' : 'td'); - const rowCells = Array.from(table.querySelectorAll(tag + '[data-row="'+row+'"]')) - .filter(el => el.getAttribute('data-col') !== null && el.getAttribute('data-col') !== '-1'); - const max = rowCells.reduce((acc, el) => Math.max(acc, parseInt(el.getAttribute('data-col'))), -1); - target = rowCells.find(el => parseInt(el.getAttribute('data-col')) === max) || refCell; - } else if (['ArrowUp','Up'].includes(e.key)) { - if (hasHeader) { - target = table.querySelector('th[data-row="0"][data-col="'+col+'"]') || refCell; - } else { - target = table.querySelector('td[data-row="0"][data-col="'+col+'"]') || refCell; - } - } else if (['ArrowDown','Down'].includes(e.key)) { - const colCells = Array.from(table.querySelectorAll('td[data-col="'+col+'"]')); - target = (colCells.length ? colCells[colCells.length - 1] : null) || refCell; - } - } - - if (target) { - clearSelection(); - target.classList.add('selected'); - currentSelection.push(target); - anchorCell = target; - rangeEndCell = target; - persistState(); - target.focus({preventScroll:true}); - target.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); - } - return; - } - if (!editingCell && e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); const refCell = anchorCell || getCellTarget(document.activeElement) || currentSelection[0] || document.querySelector('td.selected, th.selected'); @@ -1725,16 +1748,15 @@ document.addEventListener('keydown', e => { case 'ArrowLeft': targetCol = col - 1; break; case 'ArrowRight':targetCol = col + 1; break; } - if(targetRow < 0 || targetCol < 0) return; + if(targetRow < 0 || targetCol < (isRowIndexCell(anchorCell) ? -1 : 0)) return; const tag = (hasHeader && targetRow === 0 ? 'th' : 'td'); const nextCell = table.querySelector(`${tag}[data-row="${targetRow}"][data-col="${targetCol}"]`); if(nextCell){ e.preventDefault(); - rangeEndCell = nextCell; - selectRange(getCellCoords(anchorCell), getCellCoords(rangeEndCell)); + extendSelectionTo(nextCell); persistState(); anchorCell.focus({preventScroll:true}); - rangeEndCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(rangeEndCell); } return; } @@ -1760,7 +1782,7 @@ document.addEventListener('keydown', e => { rangeEndCell = nextCell; persistState(); nextCell.focus({preventScroll:true}); - nextCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(nextCell); } return; } @@ -1788,7 +1810,7 @@ document.addEventListener('keydown', e => { rangeEndCell = nextCell; persistState(); nextCell.focus({preventScroll:true}); - nextCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(nextCell); }; const cellRef = editingCell; cellRef && cellRef.blur(); @@ -1969,7 +1991,7 @@ const setSingleSelection = cell => { rangeEndCell = cell; persistState(); try { cell.focus({ preventScroll: true }); } catch { try { cell.focus(); } catch {} } - cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); + scheduleCellReveal(cell); }; const NEWLINE_SENTINEL_ATTR = 'data-csv-newline-sentinel'; @@ -2185,7 +2207,7 @@ window.addEventListener('message', event => { selectRange({ row: startRow, col: startCol }, { row: endRow, col: endCol }); persistState(); try { startCell.focus({ preventScroll: true }); } catch { try { startCell.focus(); } catch {} } - endCell.scrollIntoView({ block:'nearest', inline:'nearest', behavior:'smooth' }); + scheduleCellReveal(endCell); } else if (message.type === 'findMatchesResult') { if (!findReplaceState.open) { return; diff --git a/src/test/find-shortcut-modifiers.test.ts b/src/test/find-shortcut-modifiers.test.ts new file mode 100644 index 0000000..f39d633 --- /dev/null +++ b/src/test/find-shortcut-modifiers.test.ts @@ -0,0 +1,50 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type View = ReturnType; +function key(view: View, value: string, modifiers: Record) { + const event = new view.window.KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true, ...modifiers }); + view.window.document.activeElement.dispatchEvent(event); + return event; +} + +describe('Local Find and Replace shortcut modifiers', () => { + for (const modifier of ['metaKey', 'ctrlKey']) { + for (const shortcut of ['f', 'h']) { + it(`opens local ${shortcut === 'f' ? 'Find' : 'Replace'} only for plain ${modifier}`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const widget = view.window.document.getElementById('findReplaceWidget'); + view.window.editingTestApi.setSingleSelection(view.cell()); + const event = key(view, shortcut.toUpperCase(), { [modifier]: true }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(widget.classList.contains('open'), true); + assert.strictEqual(widget.classList.contains('replace-expanded'), shortcut === 'h'); + assert.strictEqual(view.window.document.activeElement, view.window.document.getElementById('findInput')); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + for (const extra of [{ shiftKey: true }, { altKey: true }, { shiftKey: true, altKey: true }]) { + it(`leaves native ${modifier}+${Object.keys(extra).join('+')}+${shortcut.toUpperCase()} untouched`, async () => { + const host = await createEditorHarness(); + const view = createWebviewDom(host.panel.webview.html); + try { + const cell = view.cell(); + view.start(cell); + cell.textContent = 'live draft'; + const event = key(view, shortcut, { [modifier]: true, ...extra }); + assert.strictEqual(event.defaultPrevented, false); + assert.strictEqual(event.cancelBubble, false); + assert.strictEqual(view.window.document.getElementById('findReplaceWidget').classList.contains('open'), false); + assert.strictEqual(view.window.document.activeElement, cell); + assert.strictEqual(cell.getAttribute('contenteditable'), 'true'); + assert.ok(!view.messages.some(message => message.type === 'editCell' || message.type === 'findMatches')); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); + } + } + } +}); diff --git a/src/test/header-click-behavior.test.ts b/src/test/header-click-behavior.test.ts new file mode 100644 index 0000000..804f677 --- /dev/null +++ b/src/test/header-click-behavior.test.ts @@ -0,0 +1,158 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type View = ReturnType; +const rows = [['Name', 'Value', 'https://example.com'], ['one', 'two', 'three'], ['four', 'five', 'six']]; +async function fixture(separator = ',', header = true) { + const host = await createEditorHarness({ rows, separator, header }); + const view = createWebviewDom(host.panel.webview.html); + const table = view.window.document.querySelector('table'); + table.getBoundingClientRect = () => ({ left: 0, right: 400, top: 0, bottom: 120, width: 400, height: 120 }); + for (const cell of Array.from(table.querySelectorAll('[data-row][data-col]')) as any[]) { + const col = Number(cell.dataset.col), top = Number(cell.dataset.row) * 30; + cell.getBoundingClientRect = () => { + let left = 0; + for (let previous = -1; previous < col; previous++) { + const sibling = table.querySelector(`[data-row="${cell.dataset.row}"][data-col="${previous}"]`); + left += Number.parseFloat(sibling?.style.width) || 100; + } + const width = Number.parseFloat(cell.style.width) || 100; + return { left, right: left + width, top, bottom: top + 30, width, height: 30 }; + }; + } + return { host, view }; +} +function mouse(view: View, target: any, type: string, options: Record = {}) { + const cell = target.closest('td, th'); + const rect = cell.getBoundingClientRect(); + const event = new view.window.MouseEvent(type, { button: 0, buttons: type === 'mousemove' ? 1 : 0, detail: 1, bubbles: true, cancelable: true, clientX: rect.left + rect.width / 2, clientY: rect.top + 15, ...options }); + target.dispatchEvent(event); + return event; +} +function click(view: View, target: any, detail = 1, options: Record = {}) { + for (const type of ['mousedown', 'mouseup', 'click']) { mouse(view, target, type, { detail, ...options }); } +} +function selected(view: View) { return Array.from(view.window.document.querySelectorAll('td.selected, th.selected')) as any[]; } +function column(view: View, index: number) { return Array.from(view.window.document.querySelectorAll(`[data-col="${index}"]`)); } + +describe('Separate header clicks', () => { + for (const separator of [',', '\t']) { + it(`selects a new column immediately, then only its header on a second separate click (${JSON.stringify(separator)})`, async () => { + const { host, view } = await fixture(separator); + try { + const header = view.cell(0, 1); + const span = view.window.document.createElement('span'); + span.textContent = header.textContent; + header.replaceChildren(span); + mouse(view, span, 'mousedown'); + assert.deepStrictEqual(selected(view), column(view, 1), 'first press retains immediate whole-column selection'); + mouse(view, span, 'mouseup'); mouse(view, span, 'click'); + mouse(view, span, 'mousedown'); + assert.deepStrictEqual(selected(view), column(view, 1), 'a potential reorder keeps the column selected until release'); + mouse(view, span, 'mouseup'); mouse(view, span, 'click'); + assert.deepStrictEqual(selected(view), [header]); + assert.strictEqual(view.window.document.activeElement, header); + assert.strictEqual(header.getAttribute('contenteditable'), null); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorRow, 0); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorCol, 1); + view.key('c', { metaKey: true }); + const copy = view.messages.filter(message => message.type === 'copyCells').pop(); + assert.deepStrictEqual(JSON.parse(JSON.stringify(copy.matrix)), [['Value']]); + assert.ok(!view.messages.some(message => message.type === 'reorderColumns' || message.type === 'editCell')); + assert.strictEqual(host.document.version, 1); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + }); + } + + it('keeps a true detail-2 double-click available for editing and committing the header', async () => { + const { host, view } = await fixture(); + try { + const header = view.cell(0, 1); + click(view, header, 1); + click(view, header, 2); + assert.deepStrictEqual(selected(view), column(view, 1)); + mouse(view, header, 'dblclick', { detail: 2 }); + assert.strictEqual(header.getAttribute('contenteditable'), 'true'); + header.textContent = 'Changed header'; + header.blur(); + const edit = view.messages.find(message => message.type === 'editCell'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(edit)), { type: 'editCell', row: 0, col: 1, value: 'Changed header' }); + await host.receive(edit); + assert.strictEqual(host.rows()[0][1], 'Changed header'); + assert.ok(!host.diskText.includes('Changed header')); + assert.ok(!view.messages.some(message => message.type === 'reorderColumns')); + } finally { view.close(); } + }); + + it('continues to reorder an already selected header instead of collapsing it during a drag', async () => { + const { view } = await fixture(); + try { + const header = view.cell(0, 1); + click(view, header); + mouse(view, header, 'mousedown'); + mouse(view, view.cell(0, 2), 'mousemove', { clientX: 390 }); + mouse(view, view.cell(0, 2), 'mouseup', { clientX: 390 }); + assert.deepStrictEqual(selected(view), column(view, 1)); + const reorder = view.messages.find(message => message.type === 'reorderColumns'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(reorder)), { type: 'reorderColumns', indices: [1], beforeIndex: 3 }); + } finally { view.close(); } + }); + + it('preserves Shift column groups, dragging those groups, and right-click selection', async () => { + const { view } = await fixture(); + try { + const first = view.cell(0, 0), second = view.cell(0, 1); + click(view, first); + click(view, first, 1, { shiftKey: true }); + assert.deepStrictEqual(selected(view), column(view, 0)); + click(view, second, 1, { shiftKey: true }); + const group = selected(view); + assert.strictEqual(group.length, column(view, 0).length + column(view, 1).length); + mouse(view, first, 'mousedown', { button: 2 }); + mouse(view, first, 'mouseup', { button: 2 }); + assert.deepStrictEqual(selected(view), group); + mouse(view, second, 'mousedown'); + mouse(view, view.cell(0, 2), 'mousemove', { clientX: 390 }); + mouse(view, view.cell(0, 2), 'mouseup', { clientX: 390 }); + assert.deepStrictEqual(selected(view), group); + const reorder = view.messages.find(message => message.type === 'reorderColumns'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(reorder.indices)), [0, 1]); + } finally { view.close(); } + }); + + it('keeps resize boundaries ahead of repeat-click selection and double-click editing', async () => { + const { view } = await fixture(); + try { + const header = view.cell(0, 1); + click(view, header); + mouse(view, header, 'mousedown', { clientX: 299 }); + mouse(view, header, 'mousemove', { clientX: 339 }); + mouse(view, header, 'mouseup', { clientX: 339 }); + assert.strictEqual(header.style.width, '140px'); + assert.deepStrictEqual(selected(view), column(view, 1)); + mouse(view, header, 'dblclick', { detail: 2, clientX: 339 }); + assert.strictEqual(header.style.width, ''); + assert.notStrictEqual(header.getAttribute('contenteditable'), 'true'); + assert.deepStrictEqual(selected(view), column(view, 1)); + } finally { view.close(); } + }); + + it('leaves Cmd/Ctrl link clicks and repeated serial-index clicks unchanged', async () => { + const { view } = await fixture(); + try { + click(view, view.cell(0, 1)); + const before = selected(view); + const link = view.cell(0, 2).querySelector('.csv-link'); + assert.ok(link); + click(view, link, 1, { metaKey: true }); + assert.deepStrictEqual(selected(view), before); + assert.strictEqual(view.messages.filter(message => message.type === 'openLink').length, 1); + const index = view.cell(1, -1); + click(view, index); click(view, index); + assert.strictEqual(selected(view).length, 4); + assert.ok(selected(view).every(cell => cell.dataset.row === '1')); + } finally { view.close(); } + }); +}); diff --git a/src/test/modified-arrow-selection.test.ts b/src/test/modified-arrow-selection.test.ts new file mode 100644 index 0000000..7ad5556 --- /dev/null +++ b/src/test/modified-arrow-selection.test.ts @@ -0,0 +1,228 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type View = ReturnType; +const rows = [['A', 'B', 'C', 'D'], ['a1', 'a2', 'a3', 'a4'], ['b1', 'b2', 'b3', 'b4'], ['c1', 'c2', 'c3', 'c4'], ['d1', 'd2', 'd3', 'd4']]; + +async function fixture(options: Parameters[0] = {}) { + const host = await createEditorHarness({ rows, ...options }); + const view = createWebviewDom(host.panel.webview.html); + const container = view.window.document.querySelector('.table-container'); + container.scrollTo = (position: { left?: number; top?: number }) => { + if (position.left !== undefined) { container.scrollLeft = position.left; } + if (position.top !== undefined) { container.scrollTop = position.top; } + }; + view.window.scrollTo = () => {}; + return { host, view }; +} + +function select(view: View, row = 2, column = 1) { + view.window.editingTestApi.setSingleSelection(view.cell(row, column)); +} +function selected(view: View, includeIndex = false): number[][] { + return Array.from(view.window.document.querySelectorAll('td.selected, th.selected')) + .filter((cell: any) => cell.hasAttribute('data-row') && Number(cell.getAttribute('data-col')) >= (includeIndex ? -1 : 0)) + .map((cell: any) => [Number(cell.getAttribute('data-row')), Number(cell.getAttribute('data-col'))]); +} +function rectangle(minRow: number, maxRow: number, minColumn: number, maxColumn: number) { + return Array.from({ length: maxRow - minRow + 1 }, (_, row) => + Array.from({ length: maxColumn - minColumn + 1 }, (_, column) => [minRow + row, minColumn + column]) + ).flat(); +} +function mouseSelect(view: View, row: number, column: number) { + const cell = view.cell(row, column); + const left = (column + 1) * 100; + cell.getBoundingClientRect = () => ({ left, right: left + 100, top: row * 30, bottom: row * 30 + 30, width: 100, height: 30 }); + cell.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 0, clientX: left + 50, clientY: row * 30 + 15, bubbles: true, cancelable: true })); + cell.dispatchEvent(new view.window.MouseEvent('mouseup', { button: 0, bubbles: true })); +} +function dispatch(view: View, key: string, options: Record) { + const event = new view.window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }); + view.window.document.activeElement.dispatchEvent(event); + return event; +} + +describe('Modified arrow range selection', () => { + for (const modifier of ['metaKey', 'ctrlKey']) { + for (const [key, bounds] of [ + ['ArrowLeft', [2, 2, 0, 1]], ['ArrowRight', [2, 2, 1, 3]], + ['ArrowUp', [1, 2, 1, 1]], ['ArrowDown', [2, 5, 1, 1]] + ] as const) { + it(`extends from a fixed anchor with ${modifier} + Shift + ${key}`, async () => { + const { host, view } = await fixture(); + const source = host.document.getText(); + try { + select(view); + const anchor = view.cell(2, 1); + const event = dispatch(view, key, { [modifier]: true, shiftKey: true }); + assert.strictEqual(event.defaultPrevented, true); + assert.deepStrictEqual(selected(view), rectangle(bounds[0], bounds[1], bounds[2], bounds[3])); + assert.strictEqual(view.window.document.activeElement, anchor); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorRow, 2); + assert.strictEqual(view.window.acquireVsCodeApi().getState().anchorCol, 1); + assert.strictEqual(host.document.getText(), source); + assert.ok(!view.messages.some((message: any) => message.type === 'editCell')); + } finally { view.close(); } + }); + } + + it(`continues and shrinks a rectangle from its endpoint with ${modifier}`, async () => { + const { view } = await fixture(); + try { + select(view); + view.key('ArrowDown', { shiftKey: true }); + view.key('ArrowRight', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(2, 3, 1, 3)); + view.key('ArrowLeft', { shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(2, 3, 1, 2)); + view.key('ArrowLeft', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(2, 3, 0, 1)); + view.key('ArrowUp', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(1, 2, 0, 1)); + assert.strictEqual(view.window.document.activeElement, view.cell(2, 1)); + } finally { view.close(); } + }); + + it(`retains whole-column groups during horizontal and vertical extension with ${modifier}`, async () => { + const { view } = await fixture(); + try { + mouseSelect(view, 0, 1); + view.key('ArrowRight', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(0, 5, 1, 3)); + view.key('ArrowDown', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(0, 5, 1, 3)); + view.key('ArrowLeft', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(0, 5, 0, 1)); + assert.strictEqual(view.window.document.activeElement, view.cell(0, 1)); + } finally { view.close(); } + }); + + it(`retains whole-row groups during vertical and horizontal extension with ${modifier}`, async () => { + const { view } = await fixture(); + try { + mouseSelect(view, 2, -1); + view.key('ArrowDown', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view, true), rectangle(2, 5, -1, 3)); + view.key('ArrowRight', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view, true), rectangle(2, 5, -1, 3)); + view.key('ArrowUp', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view, true), rectangle(1, 2, -1, 3)); + assert.strictEqual(view.window.document.activeElement, view.cell(2, -1)); + } finally { view.close(); } + }); + + it(`keeps a backwards drag anchor through repeated and mixed jumps with ${modifier}`, async () => { + const { view } = await fixture(); + try { + mouseSelect(view, 3, 2); + view.cell(3, 2).dispatchEvent(new view.window.MouseEvent('mousedown', { button: 0, clientX: 350, clientY: 105, bubbles: true })); + view.cell(1, 0).dispatchEvent(new view.window.MouseEvent('mousemove', { buttons: 1, bubbles: true })); + view.cell(1, 0).dispatchEvent(new view.window.MouseEvent('mouseup', { button: 0, bubbles: true })); + assert.deepStrictEqual(selected(view), rectangle(1, 3, 0, 2)); + for (let repeat = 0; repeat < 2; repeat++) { + view.key('ArrowRight', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(1, 3, 2, 3)); + } + view.key('ArrowDown', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(3, 5, 2, 3)); + view.key('ArrowLeft', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(3, 5, 0, 2)); + view.key('ArrowUp', { shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(3, 4, 0, 2)); + view.key('ArrowUp', { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(1, 3, 0, 2)); + assert.strictEqual(view.window.document.activeElement, view.cell(3, 2)); + } finally { view.close(); } + }); + + it(`preserves Select All after a stale range endpoint with ${modifier}`, async () => { + const { host, view } = await fixture(); + try { + select(view); + view.key('ArrowDown', { shiftKey: true }); + const corner = view.window.document.querySelector('thead th:not([data-col])'); + corner.dispatchEvent(new view.window.MouseEvent('mousedown', { button: 0, bubbles: true })); + corner.dispatchEvent(new view.window.MouseEvent('mouseup', { button: 0, bubbles: true })); + const all = selected(view, true); + assert.ok(all.length > 1); + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { + view.key(key, { [modifier]: true, shiftKey: true }); + assert.deepStrictEqual(selected(view, true), all); + } + assert.deepStrictEqual(host.rows(), rows); + } finally { view.close(); } + }); + + it(`preserves plain ${modifier} jump behavior without Shift`, async () => { + const { view } = await fixture(); + try { + select(view); + view.key('ArrowRight', { [modifier]: true }); + assert.deepStrictEqual(selected(view), [[2, 3]]); + assert.strictEqual(view.window.document.activeElement, view.cell(2, 3)); + view.key('ArrowDown', { [modifier]: true }); + assert.deepStrictEqual(selected(view), [[5, 3]]); + } finally { view.close(); } + }); + } + + it('extends to the existing loaded-row boundary in a chunked table', async () => { + const source = [['A', 'B'], ...Array.from({ length: 1100 }, (_, row) => [`row${row}`, String(row)])]; + const { host, view } = await fixture({ rows: source }); + try { + const rendered = Array.from(view.window.document.querySelectorAll('tbody td[data-col="1"]')) as any[]; + const lastRow = Number(rendered[rendered.length - 1].getAttribute('data-row')); + assert.ok(lastRow < source.length - 1, 'fixture must retain unloaded source rows'); + select(view, lastRow - 2, 1); + view.key('ArrowDown', { metaKey: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(lastRow - 2, lastRow, 1, 1)); + assert.strictEqual(view.window.document.activeElement, view.cell(lastRow - 2, 1)); + assert.deepStrictEqual(host.rows(), source); + } finally { view.close(); } + }); + + it('copies the entire extended rectangle rather than only its edge cell', async () => { + const { view } = await fixture(); + try { + select(view); + view.key('ArrowDown', { shiftKey: true }); + view.key('ArrowRight', { metaKey: true, shiftKey: true }); + view.key('c', { metaKey: true }); + const copy = view.messages.slice().reverse().find((message: any) => message.type === 'copyCells'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(copy.matrix)), [['b2', 'b3', 'b4'], ['c2', 'c3', 'c4']]); + } finally { view.close(); } + }); + + it('supports headers off and Home/End aliases without losing the anchor', async () => { + const { view } = await fixture({ header: false, separator: '\t' }); + try { + select(view); + view.key('End', { metaKey: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(2, 2, 1, 3)); + view.key('Home', { metaKey: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(2, 2, 0, 1)); + view.key('ArrowUp', { metaKey: true, shiftKey: true }); + assert.deepStrictEqual(selected(view), rectangle(0, 2, 0, 1)); + } finally { view.close(); } + }); + + it('leaves native selection in Find, Replace and cell text edits outside the grid handler', async () => { + const { view } = await fixture(); + try { + select(view); + const before = selected(view); + for (const id of ['findInput', 'replaceInput']) { + view.window.document.getElementById(id).focus(); + for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { + assert.strictEqual(dispatch(view, key, { metaKey: true, shiftKey: true }).defaultPrevented, false); + } + assert.deepStrictEqual(selected(view), before); + } + view.start(view.cell(2, 1)); + for (const key of ['ArrowLeft', 'ArrowRight']) { + assert.strictEqual(dispatch(view, key, { metaKey: true, shiftKey: true }).defaultPrevented, false); + } + } finally { view.close(); } + }); +}); diff --git a/src/test/mouse-selection.test.ts b/src/test/mouse-selection.test.ts new file mode 100644 index 0000000..a5be3a9 --- /dev/null +++ b/src/test/mouse-selection.test.ts @@ -0,0 +1,140 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type View = ReturnType; +const rows = [['Name', 'Value', 'Note'], ['one', 'two', 'three'], ['four', 'five', 'six'], ['seven', 'eight', 'nine']]; +const mouse = (view: View, target: any, type: string, options: Record = {}) => + target.dispatchEvent(new view.window.MouseEvent(type, { button: 0, bubbles: true, cancelable: true, clientX: 60, clientY: 45, ...options })); +const selected = (view: View) => Array.from(view.window.document.querySelectorAll('td.selected, th.selected')) as any[]; +const coordinates = (view: View) => selected(view).map(cell => [Number(cell.dataset.row), Number(cell.dataset.col)]).sort((a, b) => a[0] - b[0] || a[1] - b[1]); + +async function fixture(separator = ',') { + const host = await createEditorHarness({ rows, separator }); + return { host, view: createWebviewDom(host.panel.webview.html) }; +} + +describe('Immediate mouse selection', () => { + for (const separator of [',', '\t']) { + it(`selects a body cell on mousedown and replaces the old selection on a new target (${JSON.stringify(separator)})`, async () => { + const { host, view } = await fixture(separator); + try { + const first = view.cell(1, 0); + mouse(view, first, 'mousedown'); + assert.strictEqual(first.classList.contains('selected'), true, 'selection color must apply before mouseup'); + assert.deepStrictEqual(coordinates(view), [[1, 0]]); + mouse(view, first, 'mouseup'); + const next = view.cell(2, 1); + mouse(view, next, 'mousedown'); + assert.deepStrictEqual(coordinates(view), [[2, 1]], 'a new press must immediately replace the prior selection'); + assert.strictEqual(view.window.document.activeElement, next); + mouse(view, next, 'mouseup'); + assert.deepStrictEqual(coordinates(view), [[2, 1]]); + assert.deepStrictEqual(host.rows(), rows); + assert.strictEqual(host.document.version, 1); + assert.strictEqual(host.document.getText(), host.diskText); + } finally { view.close(); } + }); + } + + it('expands and shrinks a held body drag without retaining cells outside its current rectangle', async () => { + const { view } = await fixture(); + try { + mouse(view, view.cell(1, 0), 'mousedown'); + assert.deepStrictEqual(coordinates(view), [[1, 0]]); + mouse(view, view.cell(3, 2), 'mousemove'); + assert.strictEqual(selected(view).length, 9); + mouse(view, view.cell(2, 1), 'mousemove'); + assert.deepStrictEqual(coordinates(view), [[1, 0], [1, 1], [2, 0], [2, 1]]); + mouse(view, view.cell(2, 1), 'mouseup'); + assert.deepStrictEqual(coordinates(view), [[1, 0], [1, 1], [2, 0], [2, 1]]); + } finally { view.close(); } + }); + + it('selects the full new header column on mousedown before a column drag', async () => { + const { view } = await fixture(); + try { + const column = view.cell(0, 1); + mouse(view, column, 'mousedown'); + assert.deepStrictEqual(selected(view), Array.from(view.window.document.querySelectorAll('[data-col="1"]'))); + mouse(view, view.cell(0, 2), 'mousemove'); + assert.ok(selected(view).every(cell => cell.dataset.col === '1' || cell.dataset.col === '2')); + assert.strictEqual(selected(view).length, view.window.document.querySelectorAll('[data-col="1"], [data-col="2"]').length); + mouse(view, column, 'mousemove'); + mouse(view, column, 'mouseup'); + assert.deepStrictEqual(selected(view), Array.from(view.window.document.querySelectorAll('[data-col="1"]'))); + } finally { view.close(); } + }); + + it('selects the full new serial row on mousedown before a row drag', async () => { + const { view } = await fixture(); + try { + const row = view.cell(1, -1); + mouse(view, row, 'mousedown'); + assert.deepStrictEqual(selected(view), Array.from(view.window.document.querySelectorAll('[data-row="1"]'))); + mouse(view, view.cell(3, -1), 'mousemove'); + assert.strictEqual(selected(view).length, 12); + mouse(view, view.cell(2, -1), 'mousemove'); + assert.strictEqual(selected(view).length, 8); + mouse(view, view.cell(2, -1), 'mouseup'); + assert.ok(selected(view).every(cell => cell.dataset.row === '1' || cell.dataset.row === '2')); + } finally { view.close(); } + }); + + it('preserves Shift range extension and an existing range on right-click', async () => { + const { view } = await fixture(); + try { + mouse(view, view.cell(1, 0), 'mousedown'); + mouse(view, view.cell(1, 0), 'mouseup'); + mouse(view, view.cell(3, 2), 'mousedown', { shiftKey: true }); + assert.strictEqual(selected(view).length, 9); + const before = coordinates(view); + mouse(view, view.cell(2, 1), 'mousedown', { button: 2 }); + assert.deepStrictEqual(coordinates(view), before); + } finally { view.close(); } + }); + + it('commits edited A and copies only newly pressed B before and after mouseup', async () => { + const { host, view } = await fixture(); + try { + const first = view.cell(1, 0); + const next = view.cell(2, 1); + view.start(first); + first.textContent = 'committed draft\nsecond line'; + const cursor = view.messages.length; + mouse(view, next, 'mousedown'); + const edit = view.messages.slice(cursor).find(message => message.type === 'editCell'); + assert.ok(edit, 'pressing a different cell must still commit the active edit'); + assert.deepStrictEqual(JSON.parse(JSON.stringify(edit)), { type: 'editCell', row: 1, col: 0, value: 'committed draft\nsecond line' }); + next.dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + const beforeMouseup = view.messages.slice(cursor).filter(message => message.type === 'copyCells').slice(-1)[0]; + assert.ok(beforeMouseup); + assert.deepStrictEqual(JSON.parse(JSON.stringify(beforeMouseup.matrix)), [['five']], 'only B may be copied while the mouse is held'); + assert.strictEqual(beforeMouseup.startRow, 2); + mouse(view, next, 'mouseup'); + next.dispatchEvent(new view.window.Event('copy', { bubbles: true, cancelable: true })); + const afterMouseup = view.messages.slice(cursor).filter(message => message.type === 'copyCells').slice(-1)[0]; + assert.deepStrictEqual(JSON.parse(JSON.stringify(afterMouseup.matrix)), [['five']], 'mouseup must retain the same single-cell copy'); + assert.strictEqual(afterMouseup.startRow, 2); + await host.receive(edit); + assert.strictEqual(host.rows()[1][0], 'committed draft\nsecond line'); + assert.strictEqual(host.rows()[2][1], 'five'); + assert.ok(!host.diskText.includes('committed draft')); + } finally { view.close(); } + }); + + it('keeps native text editing when mousedown stays inside the active edited cell', async () => { + const { host, view } = await fixture(); + try { + const cell = view.cell(1, 0); + view.start(cell); + const event = new view.window.MouseEvent('mousedown', { button: 0, bubbles: true, cancelable: true, clientX: 60, clientY: 45 }); + cell.dispatchEvent(event); + assert.strictEqual(event.defaultPrevented, false); + assert.strictEqual(cell.getAttribute('contenteditable'), 'true'); + assert.strictEqual(cell.classList.contains('selected'), false); + assert.strictEqual(view.messages.some(message => message.type === 'editCell'), false); + assert.strictEqual(host.document.version, 1); + } finally { view.close(); } + }); +}); diff --git a/src/test/webview-navigation-scroll.test.ts b/src/test/webview-navigation-scroll.test.ts new file mode 100644 index 0000000..a2d844d --- /dev/null +++ b/src/test/webview-navigation-scroll.test.ts @@ -0,0 +1,327 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness } from './edit-integrity-harness'; +const { JSDOM } = require('jsdom'); + +type Position = { left?: number; top?: number; behavior?: string }; + +// This geometry model runs the shipped script. Smooth native scrolls remain +// pending until explicitly completed; immediate scrolls cancel them, even at +// zero delta. Keyboard animation frames are controlled independently. +async function fixture(options: { separator?: string; rowCount?: number; columnCount?: number } = {}) { + const rowCount = options.rowCount ?? 40, columnCount = options.columnCount ?? 12; + const rows = Array.from({ length: rowCount }, (_, row) => Array.from({ length: columnCount }, (_, column) => `r${row}c${column}`)); + const host = await createEditorHarness({ rows, separator: options.separator, header: false, serialIndex: false }); + const dom = new JSDOM(host.panel.webview.html, { runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const container = window.document.querySelector('.table-container'); + const table = container.querySelector('table'); + const width = 240, height = 120, cellWidth = 80, cellHeight = 24; + const viewport = { left: 37.5, top: 29.25, right: 277.5, bottom: 149.25, width, height }; + let scrollLeft = 0, scrollTop = 0, state: any = {}; + let pendingSmooth: { left: number; top: number } | undefined; + let clock = 0, nextFrame = 1, nextTimer = 1; + const frames = new Map void>(); + const timers = new Map void>(); + const messages: any[] = []; + const scrollCalls: Array = []; + const focusCalls: Array<{ cell: any; options: any }> = []; + const renderedHeight = () => (Number(table.querySelector('tr:last-child [data-row]')?.getAttribute('data-row') ?? 0) + 1) * cellHeight; + const clamp = (value: number, maximum: number) => Math.max(0, Math.min(value, Math.max(0, maximum))); + const notifyScroll = () => container.dispatchEvent(new window.Event('scroll')); + const instant = (position: Position) => { + pendingSmooth = undefined; + if (position.left !== undefined) { scrollLeft = clamp(position.left, columnCount * cellWidth - width); } + if (position.top !== undefined) { scrollTop = clamp(position.top, renderedHeight() - height); } + notifyScroll(); + }; + const scroll = (position: Position) => { + scrollCalls.push({ ...position }); + const left = clamp(position.left ?? scrollLeft, columnCount * cellWidth - width); + const top = clamp(position.top ?? scrollTop, renderedHeight() - height); + if (position.behavior === 'smooth') { + // A no-op smooth reveal does not cancel an already running native scroll. + if (left !== scrollLeft || top !== scrollTop) { pendingSmooth = { left, top }; } + } else { instant({ left, top }); } + }; + Object.defineProperties(container, { + clientWidth: { get: () => width }, clientHeight: { get: () => height }, + clientLeft: { get: () => 0 }, clientTop: { get: () => 0 }, + scrollWidth: { get: () => columnCount * cellWidth }, scrollHeight: { get: renderedHeight }, + scrollLeft: { get: () => scrollLeft, set: (value: number) => instant({ left: value }) }, + scrollTop: { get: () => scrollTop, set: (value: number) => instant({ top: value }) } + }); + container.getBoundingClientRect = () => ({ ...viewport }); + container.scrollTo = scroll; + window.scrollTo = scroll; + window.requestAnimationFrame = (callback: (time: number) => void) => { const id = nextFrame++; frames.set(id, callback); return id; }; + window.cancelAnimationFrame = (id: number) => { frames.delete(id); }; + window.setTimeout = (callback: () => void) => { const id = nextTimer++; timers.set(id, callback); return id; }; + window.clearTimeout = (id: number) => { timers.delete(id); }; + window.IntersectionObserver = class { observe() {} disconnect() {} }; + window.acquireVsCodeApi = () => ({ postMessage: (message: any) => messages.push(message), getState: () => state, setState: (value: any) => { state = value; } }); + const nativeRect = window.HTMLElement.prototype.getBoundingClientRect; + window.HTMLElement.prototype.getBoundingClientRect = function (this: any) { + if (!this.hasAttribute('data-row') || !this.hasAttribute('data-col')) { return nativeRect.call(this); } + const row = Number(this.getAttribute('data-row')), column = Number(this.getAttribute('data-col')); + let left = column * cellWidth - scrollLeft, top = row * cellHeight - scrollTop; + if (this.style.position === 'sticky') { + if (this.style.left !== '') { left = Math.max(left, Number.parseFloat(this.style.left)); } + if (this.style.top !== '') { top = Math.max(top, Number.parseFloat(this.style.top)); } + } + left += viewport.left; + top += viewport.top; + return { left, top, right: left + cellWidth, bottom: top + cellHeight, width: cellWidth, height: cellHeight }; + }; + window.HTMLElement.prototype.scrollIntoView = function (this: any, settings: any = {}) { + const rect = this.getBoundingClientRect(); + const nearest = (start: number, end: number, low: number, high: number) => start < low ? start - low : end > high ? end - high : 0; + scroll({ + left: scrollLeft + (settings.inline === 'center' ? (rect.left + rect.right - viewport.left - viewport.right) / 2 : nearest(rect.left, rect.right, viewport.left, viewport.right)), + top: scrollTop + (settings.block === 'center' ? (rect.top + rect.bottom - viewport.top - viewport.bottom) / 2 : nearest(rect.top, rect.bottom, viewport.top, viewport.bottom)), + behavior: settings.behavior + }); + }; + const nativeFocus = window.HTMLElement.prototype.focus; + window.HTMLElement.prototype.focus = function (this: any, settings: any) { + focusCalls.push({ cell: this, options: settings }); + nativeFocus.call(this, settings); + }; + const flush = () => { + for (let guard = 0; (frames.size || timers.size) && guard < 20; guard++) { + const pendingTimers = [...timers.values()]; + timers.clear(); + pendingTimers.forEach(callback => callback()); + const callbacks = [...frames.values()]; + frames.clear(); + clock += 16; + callbacks.forEach(callback => callback(clock)); + } + assert.ok(!frames.size && !timers.size, 'scheduled work settles without another key'); + }; + const finishSmooth = () => { + if (!pendingSmooth) { return; } + const position = pendingSmooth; + instant(position); + }; + const scriptPath = process.env.CSV_NAVIGATION_TEST_SCRIPT || path.join(process.cwd(), 'media/main.js'); + window.eval(`${fs.readFileSync(scriptPath, 'utf8')}\nwindow.navigationTestApi = { select: setSingleSelection, anchor: () => anchorCell, endpoint: () => rangeEndCell };`); + flush(); + const cell = (row: number, column: number) => table.querySelector(`[data-row="${row}"][data-col="${column}"]`); + const select = (row: number, column: number) => { + window.navigationTestApi.select(cell(row, column)); + flush(); + finishSmooth(); // settle only the explicit starting selection, before held keys + scrollCalls.length = 0; + focusCalls.length = 0; + }; + const key = (name: string, settings: Record = {}) => { + const event = new window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true, ...settings }); + window.document.activeElement.dispatchEvent(event); + return event; + }; + const visible = (target: any, topInset = 0, leftInset = 0) => { + const rect = target.getBoundingClientRect(); + assert.ok(rect.top >= viewport.top + topInset && rect.bottom <= viewport.bottom, `row ${target.getAttribute('data-row')} must remain in the usable viewport`); + assert.ok(rect.left >= viewport.left + leftInset && rect.right <= viewport.right, `column ${target.getAttribute('data-col')} must remain in the usable viewport`); + }; + return { host, window, container, table, messages, scrollCalls, focusCalls, cell, select, key, flush, finishSmooth, visible, + get state() { return state; }, get pendingSmooth() { return pendingSmooth; }, get frameCount() { return frames.size; }, + anchor: () => window.navigationTestApi.anchor(), endpoint: () => window.navigationTestApi.endpoint(), + close: () => window.close() }; +} + +describe('Keyboard navigation viewport reveal', () => { + for (const separator of [',', '\t']) { + it(`keeps held vertical arrows revealed at every paint without a fresh key (${JSON.stringify(separator)})`, async () => { + const view = await fixture({ separator }); + try { + view.select(0, 0); + for (let row = 1; row <= 16; row++) { + assert.strictEqual(view.key('ArrowDown', { repeat: row > 1 }).defaultPrevented, true); + view.flush(); + assert.strictEqual(view.window.document.activeElement, view.cell(row, 0)); + view.visible(view.cell(row, 0)); + } + assert.strictEqual(view.pendingSmooth, undefined); + assert.ok(view.scrollCalls.every(call => call.behavior !== 'smooth')); + assert.deepStrictEqual(view.host.rows()[0][0], 'r0c0'); + assert.strictEqual(view.host.document.version, 1); + } finally { view.close(); } + }); + } + + it('coalesces a burst to its newest horizontal target and reveals reversals', async () => { + const view = await fixture(); + try { + view.select(1, 0); + for (let column = 1; column <= 10; column++) { view.key('ArrowRight', { repeat: true }); } + assert.strictEqual(view.window.document.activeElement, view.cell(1, 10)); + assert.strictEqual(view.frameCount, 1, 'one pending reveal for the newest target'); + view.flush(); + assert.strictEqual(view.scrollCalls.length, 1); + view.visible(view.cell(1, 10)); + for (let column = 9; column >= 1; column--) { + view.key('ArrowLeft', { repeat: true }); + view.flush(); + view.visible(view.cell(1, column)); + } + assert.strictEqual(view.window.document.activeElement, view.cell(1, 1)); + assert.strictEqual(view.pendingSmooth, undefined); + } finally { view.close(); } + }); + + it('cancels an older native smooth scroll even when the new target is already visible', async () => { + const view = await fixture(); + try { + view.select(0, 0); + view.container.scrollTo({ top: 700, left: 700, behavior: 'smooth' }); + assert.ok(view.pendingSmooth); + view.key('ArrowRight'); + view.flush(); + assert.strictEqual(view.pendingSmooth, undefined); + view.finishSmooth(); + assert.strictEqual(view.container.scrollTop, 0); + assert.strictEqual(view.container.scrollLeft, 0); + view.visible(view.cell(0, 1)); + } finally { view.close(); } + }); + + it('keeps the Shift endpoint revealed while focus and persisted anchor remain fixed', async () => { + const view = await fixture(); + try { + view.select(1, 1); + for (let row = 2; row <= 15; row++) { + view.key('ArrowDown', { shiftKey: true, repeat: true }); + view.flush(); + view.visible(view.cell(row, 1)); + assert.strictEqual(view.window.document.activeElement, view.cell(1, 1)); + } + for (let column = 2; column <= 8; column++) { + view.key('ArrowRight', { shiftKey: true, repeat: true }); + view.flush(); + view.visible(view.cell(15, column)); + } + assert.strictEqual(view.endpoint(), view.cell(15, 8)); + assert.strictEqual(view.anchor(), view.cell(1, 1)); + assert.strictEqual(view.state.anchorRow, 1); + assert.strictEqual(view.state.anchorCol, 1); + assert.strictEqual(view.window.document.querySelectorAll('td.selected').length, 15 * 8); + assert.ok(view.focusCalls.every(call => call.options?.preventScroll === true)); + } finally { view.close(); } + }); + + it('reveals a quick-edit commit and subsequent repeat navigation without saving or re-entering edit', async () => { + const view = await fixture(); + try { + view.select(3, 1); + view.key('x'); + assert.strictEqual(view.cell(3, 1).getAttribute('contenteditable'), 'true'); + for (let row = 4; row <= 14; row++) { + view.key('ArrowDown', { repeat: row > 4 }); + view.flush(); + view.visible(view.cell(row, 1)); + } + const edits = view.messages.filter(message => message.type === 'editCell'); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0].value, 'x'); + assert.strictEqual(view.window.document.activeElement, view.cell(14, 1)); + assert.strictEqual(view.window.document.querySelector('[contenteditable="true"]'), null); + assert.ok(!view.messages.some(message => message.type === 'save')); + } finally { view.close(); } + }); + + it('persists final scroll/focus after repetition and restores without viewport drift', async () => { + const view = await fixture(); + try { + view.select(0, 0); + for (let index = 0; index < 12; index++) { view.key('ArrowDown', { repeat: true }); } + for (let index = 0; index < 7; index++) { view.key('ArrowRight', { repeat: true }); } + view.flush(); + const top = view.container.scrollTop, left = view.container.scrollLeft; + assert.ok(top > 0 && left > 0); + view.window.dispatchEvent(new view.window.Event('blur')); + assert.strictEqual(view.state.scrollY, top); + assert.strictEqual(view.state.scrollX, left); + assert.strictEqual(view.state.anchorRow, 12); + assert.strictEqual(view.state.anchorCol, 7); + view.window.dispatchEvent(new view.window.Event('focus')); + view.flush(); + assert.strictEqual(view.container.scrollTop, top); + assert.strictEqual(view.container.scrollLeft, left); + assert.strictEqual(view.window.document.activeElement, view.cell(12, 7)); + view.visible(view.cell(12, 7)); + assert.ok(view.focusCalls.every(call => call.options?.preventScroll === true)); + } finally { view.close(); } + }); + + it('reveals a modified jump and a following reversal from the latest target', async () => { + const view = await fixture(); + try { + view.select(3, 1); + view.key('ArrowRight', { metaKey: true }); + view.flush(); + view.visible(view.cell(3, 11)); + assert.strictEqual(view.window.document.activeElement, view.cell(3, 11)); + view.key('ArrowLeft', { repeat: true }); + view.key('ArrowUp', { repeat: true }); + view.flush(); + view.visible(view.cell(2, 10)); + assert.strictEqual(view.pendingSmooth, undefined); + } finally { view.close(); } + }); + + it('keeps navigation visible at the existing loaded-chunk boundary without mutating rows', async () => { + const view = await fixture({ rowCount: 1100, columnCount: 2 }); + try { + const last = Number(view.table.querySelector('tr:last-child [data-row]').getAttribute('data-row')); + assert.ok(last < 1099); + view.select(last - 2, 1); + for (let index = 0; index < 6; index++) { view.key('ArrowDown', { repeat: true }); view.flush(); } + assert.strictEqual(view.window.document.activeElement, view.cell(last, 1)); + view.visible(view.cell(last, 1)); + assert.strictEqual(view.host.rows().length, 1100); + assert.strictEqual(view.host.document.version, 1); + assert.ok(!view.messages.some(message => message.type === 'editCell')); + } finally { view.close(); } + }); + + it('reveals ordinary endpoints beyond sticky rows and columns without scrolling a frozen axis', async () => { + const view = await fixture(); + try { + for (const row of Array.from(view.table.rows) as any[]) { + for (const cell of Array.from(row.cells) as any[]) { + const r = Number(cell.getAttribute('data-row')), column = Number(cell.getAttribute('data-col')); + if (r < 2) { cell.classList.add('csv-frozen-row'); cell.style.position = 'sticky'; cell.style.top = `${r * 24}px`; } + if (column === 0) { cell.classList.add('csv-frozen-column'); cell.style.position = 'sticky'; cell.style.left = '0px'; } + } + } + view.select(2, 1); + for (let index = 0; index < 12; index++) { view.key('ArrowDown', { repeat: true }); } + for (let index = 0; index < 7; index++) { view.key('ArrowRight', { repeat: true }); } + view.flush(); + view.visible(view.cell(14, 8), 48, 80); + const frozenTop = view.container.scrollTop, frozenLeft = view.container.scrollLeft; + assert.ok(frozenTop > 0 && frozenLeft > 0); + view.window.navigationTestApi.select(view.cell(1, 8)); + view.flush(); + assert.strictEqual(view.container.scrollTop, frozenTop, 'frozen row selection keeps the scrolled vertical axis'); + view.window.navigationTestApi.select(view.cell(14, 0)); + view.flush(); + assert.strictEqual(view.container.scrollLeft, frozenLeft, 'frozen column selection keeps the scrolled horizontal axis'); + view.window.navigationTestApi.select(view.cell(14, 8)); + view.flush(); + for (let index = 0; index < 12; index++) { view.key('ArrowUp', { repeat: true }); } + for (let index = 0; index < 7; index++) { view.key('ArrowLeft', { repeat: true }); } + view.flush(); + view.visible(view.cell(2, 1), 48, 80); + const top = view.container.scrollTop; + view.key('ArrowUp'); + view.flush(); + assert.strictEqual(view.container.scrollTop, top, 'a frozen target retains its own vertical axis'); + view.visible(view.cell(1, 1)); + } finally { view.close(); } + }); +});