diff --git a/README.md b/README.md index 93cf1ab..fe9074c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get: ## Features - **Interactive Editing:** Two modes — type to start a quick edit, or press Enter/double‑click for detail edit. Changes commit to the document when focus leaves the cell or CSV view; unsaved changes remain visible when you switch tabs. -- **Smart Resizing:** Automatic calculation of column widths for improved readability. +- **Smart Resizing:** Automatic widths follow the longest line in multiline cells. Drag close to a column boundary to resize; the narrow grab area leaves small columns selectable. Widths are remembered per file across closing and reopening. Double-click a column boundary to restore automatic sizing. Resizing also works with the header row hidden. - **Dynamic Color Coding:** Visual cues based on data type help you quickly identify numbers, dates, booleans, and more (with opt‑in cool palette and theme-foreground mode settings). - **Sticky Headers:** Keep column titles in view as you scroll through large datasets. - **Enhanced Keyboard Navigation:** Navigate cells with arrows and Tab/Shift+Tab; quick edits can commit with arrow keys; `Ctrl/Cmd + A` selects all; `Ctrl/Cmd + C` copies selection. diff --git a/images/resize-hit-area.jpg b/images/resize-hit-area.jpg new file mode 100644 index 0000000..43bfb24 Binary files /dev/null and b/images/resize-hit-area.jpg differ diff --git a/media/main.js b/media/main.js index 8b4678a..667db2b 100644 --- a/media/main.js +++ b/media/main.js @@ -33,6 +33,7 @@ let editingCell = null, originalCellValue = ""; let editMode = null; // 'quick' | 'detail' | null const DRAG_THRESHOLD_PX = 4; const RESIZE_HANDLE_PX = 6; +const COLUMN_RESIZE_HANDLE_PX = 4; let resizeState = null; let reorderState = null; @@ -45,6 +46,9 @@ dragIndicator.style.zIndex = '20000'; dragIndicator.style.background = '#0a84ff'; dragIndicator.style.display = 'none'; document.body.appendChild(dragIndicator); +const automaticColumnSizes = new WeakMap(); +let persistedColumnSizes = {}; +try { persistedColumnSizes = JSON.parse(root?.dataset?.columnsizes || '{}'); } catch {} let columnSizeState = {}; let rowSizeState = {}; @@ -61,13 +65,23 @@ const normalizeSizeState = (raw, minSize) => { return out; }; +const setRenderedColumnWidth = (cell, px) => { + if (!automaticColumnSizes.has(cell)) { + automaticColumnSizes.set(cell, { width: cell.style.width, minWidth: cell.style.minWidth, maxWidth: cell.style.maxWidth, boxSizing: cell.style.boxSizing }); + } + cell.style.boxSizing = 'border-box'; + cell.style.width = `${px}px`; + cell.style.minWidth = `${px}px`; + cell.style.maxWidth = `${px}px`; +}; +const persistColumnSizes = () => { + vscode.postMessage({ type: 'columnSizesChanged', columnSizes: { ...columnSizeState } }); +}; const applySizeStateToRenderedCells = () => { for (const [col, width] of Object.entries(columnSizeState)) { const px = Math.max(40, Math.round(Number(width))); table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${px}px`; - cell.style.minWidth = `${px}px`; - cell.style.maxWidth = `${px}px`; + setRenderedColumnWidth(cell, px); }); } for (const [row, height] of Object.entries(rowSizeState)) { @@ -153,7 +167,7 @@ const restoreState = () => { const st = vscode.getState() || {}; const restoredZoom = parsePositiveNumber(st.zoomScale); setZoomScale(restoredZoom ?? 1, false); - columnSizeState = normalizeSizeState(st.columnSizes, 40); + columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40); rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight()); applySizeStateToRenderedCells(); if (typeof st.scrollX === 'number' && scrollContainer) { @@ -560,17 +574,60 @@ const getRowDropTarget = clientY => { return { beforeIndex, indicatorY }; }; const getResizeEdgeInfo = (target, e) => { - if (!target) return null; - if (isColumnHeaderCell(target)) { - const col = parseInt(target.getAttribute('data-col') || 'NaN', 10); - if (!Number.isNaN(col)) { - const rect = target.getBoundingClientRect(); - const edgeDelta = rect.right - e.clientX; - if (edgeDelta >= 0 && edgeDelta <= RESIZE_HANDLE_PX) { - return { axis: 'column', index: col, rect }; - } + if (editingCell || !Number.isFinite(e.clientX) || !Number.isFinite(e.clientY)) return null; + const element = getElementTarget(e.target); + if (element?.closest('a, .csv-link, input, textarea, select, button, [contenteditable="true"], [role="button"]')) return null; + const paintedCellAt = (x, y) => { + const hit = document.elementFromPoint(x, y); + return hit && table.contains(hit) ? getCellTarget(hit) : null; + }; + const candidates = new Set(); + const add = cell => { if (cell && table.contains(cell)) candidates.add(cell); }; + if (target) { + add(target); + // A sticky seam can lie inside a partially covered scrolling cell's rect. + add(paintedCellAt(e.clientX - COLUMN_RESIZE_HANDLE_PX - 0.5, e.clientY)); + const rect = target.getBoundingClientRect(); + if (rect.width > 0 && Math.abs(e.clientX - rect.left) <= COLUMN_RESIZE_HANDLE_PX) { + // Hit the painted neighbor, which can differ from col-1 at a frozen seam. + add(paintedCellAt(rect.left - 0.5, e.clientY)); + add(paintedCellAt(rect.left - 1, e.clientY)); + add(target.previousElementSibling); + } + } else { + // Collapsed/fractional borders can target TR/TABLE rather than either cell. + for (const delta of [0, -0.5, 0.5, -1, 1]) { + add(paintedCellAt(e.clientX + delta, e.clientY)); } } + let closest = null; + let closestDistance = Infinity; + for (const cell of candidates) { + const coords = getDataCellCoords(cell); + if (!coords) continue; + const rect = cell.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || e.clientY < rect.top - 0.5 || e.clientY > rect.bottom + 0.5 || Math.abs(e.clientX - rect.right) > COLUMN_RESIZE_HANDLE_PX + 0.75) continue; + const sampleY = clamp(e.clientY, rect.top + Math.min(0.5, rect.height / 4), rect.bottom - Math.min(0.5, rect.height / 4)); + // A scrolled cell can share the frozen column's x coordinate while covered. + if (paintedCellAt(rect.right - Math.min(0.5, rect.width / 4), sampleY) !== cell) continue; + const neighbor = paintedCellAt(rect.right + 0.5, sampleY); + const next = neighbor && neighbor !== cell && neighbor.parentElement === cell.parentElement ? neighbor : cell.nextElementSibling; + const nextRect = next && getDataCellCoords(next) ? next.getBoundingClientRect() : null; + const adjacent = nextRect && nextRect.width > 0 && nextRect.left <= rect.right + 1.5 && nextRect.right > rect.right; + const contiguous = adjacent && Math.abs(nextRect.left - rect.right) <= 1.5; + const boundary = contiguous ? (rect.right + nextRect.left) / 2 : rect.right; + const leftCover = paintedCellAt(rect.left + Math.min(0.5, rect.width / 4), sampleY); + const visibleLeft = leftCover && leftCover !== cell ? Math.min(rect.right, Math.max(rect.left, leftCover.getBoundingClientRect().right)) : rect.left; + const nextWidth = adjacent ? nextRect.right - Math.max(nextRect.left, rect.right) : Infinity; + // Use the painted span at sticky seams so even partially covered cells retain a center. + const band = Math.min(COLUMN_RESIZE_HANDLE_PX, (rect.right - visibleLeft) / 4, nextWidth / 4); + const distance = Math.abs(e.clientX - boundary); + if (distance <= band && distance < closestDistance) { + closestDistance = distance; + closest = { axis: 'column', index: coords.col, rect }; + } + } + if (closest) return closest; if (isRowIndexCell(target)) { const row = parseInt(target.getAttribute('data-row') || 'NaN', 10); if (!Number.isNaN(row)) { @@ -587,17 +644,14 @@ const applyColumnWidth = (col, widthPx) => { const width = Math.max(40, Math.round(widthPx)); columnSizeState[String(col)] = width; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = `${width}px`; - cell.style.minWidth = `${width}px`; - cell.style.maxWidth = `${width}px`; + setRenderedColumnWidth(cell, width); }); }; const resetColumnWidth = col => { delete columnSizeState[String(col)]; table.querySelectorAll(`[data-col="${col}"]`).forEach(cell => { - cell.style.width = ''; - cell.style.minWidth = ''; - cell.style.maxWidth = ''; + const automatic = automaticColumnSizes.get(cell); + if (automatic) Object.assign(cell.style, automatic); }); }; const applyRowHeight = (row, heightPx) => { @@ -700,6 +754,7 @@ const onGlobalDragMove = e => { }; const onGlobalDragEnd = () => { if (resizeState) { + if (resizeState.axis === 'column') persistColumnSizes(); resizeState = null; table.style.cursor = ''; persistState(); @@ -769,6 +824,10 @@ table.addEventListener('mousedown', e => { return; } const target = getCellTarget(e.target); + if (e.button === 0 && startResizeDrag(target, e)) { + e.preventDefault(); + return; + } if (!target) return; // Preserve selection on right-click; select target if outside current selection @@ -787,10 +846,6 @@ table.addEventListener('mousedown', e => { return; // do not start drag selection on right-click } if (e.button !== 0) return; - if (!editingCell && startResizeDrag(target, e)) { - e.preventDefault(); - return; - } if (!editingCell && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && startReorderDrag(target, e)) { e.preventDefault(); return; @@ -897,10 +952,6 @@ table.addEventListener('mousemove', e => { return; } const target = getCellTarget(e.target); - if (!target) { - table.style.cursor = ''; - return; - } const edge = getResizeEdgeInfo(target, e); if (edge) { table.style.cursor = edge.axis === 'column' ? 'col-resize' : 'row-resize'; @@ -2022,6 +2073,7 @@ table.addEventListener('dblclick', e => { e.stopPropagation(); if (edge.axis === 'column') { resetColumnWidth(edge.index); + persistColumnSizes(); } else { resetRowHeight(edge.index); } diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 6e0aab0..a1484e4 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -148,6 +148,9 @@ class CsvEditorController { case 'save': await this.handleSave(); break; + case 'columnSizesChanged': + await CsvEditorProvider.setColumnSizesForUri(this.context, this.document.uri, e.columnSizes); + break; case 'copyCells': await this.copyCells(e.matrix, e.startRow, e.startCol, e.cut === true); break; @@ -1452,7 +1455,8 @@ class CsvEditorController { nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, - mouseWheelZoomInvert + mouseWheelZoomInvert, + columnSizes: CsvEditorProvider.getColumnSizesForUri(this.context, this.document.uri) }); } @@ -1705,8 +1709,9 @@ class CsvEditorController { hasRemoteChunks: boolean; mouseWheelZoomEnabled: boolean; mouseWheelZoomInvert: boolean; + columnSizes: Record; }): string { - const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args; + const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert, columnSizes } = args; const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; // Build script URI using file path for compatibility (older APIs may lack Uri.joinPath) const scriptUri = webview.asWebviewUri( @@ -1918,7 +1923,7 @@ class CsvEditorController { -
+
${tableHtml}
@@ -1978,7 +1983,9 @@ class CsvEditorController { const widths = Array(numColumns).fill(0); for (const row of data) { for (let i = 0; i < numColumns; i++){ - widths[i] = Math.max(widths[i], (row[i] || '').length); + for (const line of (row[i] || '').split(/\r\n|[\r\n]/)) { + widths[i] = Math.max(widths[i], line.length); + } } } console.log(`CSV: Column widths: ${widths}`); @@ -2226,6 +2233,7 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { public static readonly headerKey = 'csv.headerByUri'; public static readonly serialKey = 'csv.serialIndexByUri'; public static readonly sepKey = 'csv.separatorByUri'; + public static readonly columnSizesKey = 'csv.columnSizesByUri'; private static readonly DEFAULT_SEPARATOR = ','; private static readonly DEFAULT_SEPARATOR_MODE: SeparatorMode = 'extension'; private static readonly BUILTIN_SEPARATORS_BY_EXTENSION: Record = { @@ -2596,6 +2604,38 @@ export class CsvEditorProvider implements vscode.CustomTextEditorProvider { await context.workspaceState.update(CsvEditorProvider.serialKey, map); } + public static normalizeColumnSizes(raw: unknown): Record { + const sizes: Record = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return sizes; + } + for (const [key, value] of Object.entries(raw)) { + const col = Number(key); + const width = Number(value); + if (!Number.isInteger(col) || col < 0 || !Number.isFinite(width) || width < 40) { + continue; + } + sizes[String(col)] = Math.round(width); + } + return sizes; + } + + public static getColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri): Record { + const map = context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}); + return CsvEditorProvider.normalizeColumnSizes(map[uri.toString()]); + } + + public static async setColumnSizesForUri(context: vscode.ExtensionContext, uri: vscode.Uri, raw: unknown): Promise { + const map = { ...context.workspaceState.get>(CsvEditorProvider.columnSizesKey, {}) }; + const sizes = CsvEditorProvider.normalizeColumnSizes(raw); + if (Object.keys(sizes).length) { + map[uri.toString()] = sizes; + } else { + delete map[uri.toString()]; + } + await context.workspaceState.update(CsvEditorProvider.columnSizesKey, map); + } + public static getSeparatorForUri(context: vscode.ExtensionContext, uri: vscode.Uri): string | undefined { const map = context.workspaceState.get>(CsvEditorProvider.sepKey, {}); return map[uri.toString()]; diff --git a/src/test-support/sizing-webview-harness.ts b/src/test-support/sizing-webview-harness.ts new file mode 100644 index 0000000..c374f52 --- /dev/null +++ b/src/test-support/sizing-webview-harness.ts @@ -0,0 +1,159 @@ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; + +type Listener = { callback: (event: any) => void; capture: boolean }; + +// Execute the complete shipped script. The small DOM model supplies selection, +// focus and event propagation; it does not reimplement clipboard behavior. +export function createWebview(options: { state?: any; persistedColumns?: Record; header?: boolean } = {}) { + class Element { + listeners = new Map(); + attributes = new Map(); + classes = new Set(); + children: Element[] = []; + parentElement: Element | null = null; + style: Record = {}; + dataset: Record = {}; + textContent = ''; + value = ''; + isContentEditable = false; + isConnected = true; + rect = { left: 0, right: 100, top: 0, bottom: 30, width: 100, height: 30 }; + getBoundingClientRect() { return this.rect; } + get previousElementSibling(): Element | null { return this.parentElement?.children[this.parentElement.children.indexOf(this) - 1] ?? null; } + get nextElementSibling(): Element | null { return this.parentElement?.children[this.parentElement.children.indexOf(this) + 1] ?? null; } + classList = { + add: (...names: string[]) => names.forEach(name => this.classes.add(name)), + remove: (...names: string[]) => names.forEach(name => this.classes.delete(name)), + contains: (name: string) => this.classes.has(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !this.classes.has(name); + if (next) { this.classes.add(name); } else { this.classes.delete(name); } + return next; + } + }; + constructor(public tagName = 'DIV', public id = '') {} + addEventListener(type: string, callback: (event: any) => void, options?: any) { + const listeners = this.listeners.get(type) || []; + listeners.push({ callback, capture: options === true }); + this.listeners.set(type, listeners); + } + removeEventListener() {} + setAttribute(name: string, value: string) { this.attributes.set(name, value); } + getAttribute(name: string) { return this.attributes.get(name) ?? null; } + hasAttribute(name: string) { return this.attributes.has(name); } + appendChild(child: Element) { child.parentElement = this; this.children.push(child); } + contains(other: Element): boolean { return other === this || this.children.some(child => child.contains(other)); } + focus() { document.activeElement = this; } + scrollIntoView() {} + matches(selector: string): boolean { + if (selector.includes(' ')) { + const [ancestor, child] = selector.split(' '); + return this.matches(child) && !!this.parentElement?.closest(ancestor); + } + if (selector.startsWith('#')) { return this.id === selector.slice(1); } + const tag = selector.match(/^[a-z]+/i)?.[0]; + if (tag && this.tagName !== tag.toUpperCase()) { return false; } + if (selector.includes('.')) { return selector.split('.').slice(1).every(name => this.classes.has(name)); } + for (const match of selector.matchAll(/\[([^=\]]+)(?:="([^"]*)")?\]/g)) { + if (match[2] === undefined ? !this.hasAttribute(match[1]) : this.getAttribute(match[1]) !== match[2]) { return false; } + } + return true; + } + closest(selector: string): Element | null { + if (selector.split(',').some(part => this.matches(part.trim()))) { return this; } + return this.parentElement?.closest(selector) ?? null; + } + querySelectorAll(selector: string): Element[] { + const descendants = this.children.flatMap(child => [child, ...child.querySelectorAll('*')]); + return descendants.filter(child => selector === '*' || selector.split(',').some(part => child.matches(part.trim()))); + } + querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } + get innerHTML() { return ''; } + set innerHTML(_value: string) { this.children = []; } + get innerText() { return this.textContent; } + set innerText(value: string) { this.textContent = value; } + } + const document = new Element('DOCUMENT') as Element & { body: Element; activeElement: Element | null; getElementById: (id: string) => Element }; + document.body = new Element('BODY'); + document.activeElement = null; + document.appendChild(document.body); + const elements = new Map(); + const byId = (id: string) => { + if (!elements.has(id)) { + const el = new Element(id.endsWith('Input') ? 'INPUT' : 'DIV', id); + elements.set(id, el); + document.body.appendChild(el); + } + return elements.get(id)!; + }; + document.getElementById = byId; + const root = byId('csv-root'); + const table = new Element('TABLE'); + const head = new Element('THEAD'); + const rows = new Map(); + if (options.header !== false) { table.appendChild(head); } + root.appendChild(table); + root.dataset.columnsizes = JSON.stringify(options.persistedColumns || {}); + byId('__csvChunks').textContent = '[]'; + const widget = byId('findReplaceWidget'); + for (const id of ['findInput', 'replaceInput']) { + const input = byId(id); + document.body.children = document.body.children.filter(el => el !== input); + widget.appendChild(input); + } + const window = new Element('WINDOW') as any; + window.getComputedStyle = () => ({ fontSize: '14px' }); + const messages: any[] = []; + let savedState = options.state || {}; + const context = vm.createContext({ + document, window, Element, Node: Element, + acquireVsCodeApi: () => ({ getState: () => savedState, setState: (state: any) => { savedState = state; }, postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) }), + setTimeout: () => 0, clearTimeout: () => {}, requestAnimationFrame: () => 0, + console + }); + (document as any).createElement = (tag: string) => new Element(tag.toUpperCase()); + vm.runInContext(fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8'), context); + const cell = (row: number, col: number, value: string, header = false) => { + const el = new Element(header ? 'TH' : 'TD'); + el.rect = { left: col * 100, right: (col + 1) * 100, top: row * 30, bottom: (row + 1) * 30, width: 100, height: 30 }; + el.style.width = ''; el.style.minWidth = '8ch'; el.style.maxWidth = '100ch'; el.style.boxSizing = ''; + el.setAttribute('data-row', String(row)); + el.setAttribute('data-col', String(col)); + el.textContent = value; + const key = `${header}:${row}`; + if (!rows.has(key)) { + const tr = new Element('TR'); + (header ? head : table).appendChild(tr); + rows.set(key, tr); + } + rows.get(key)!.appendChild(el); + return el; + }; + (document as any).elementFromPoint = (x: number, y: number) => table.querySelectorAll('td, th').find(cell => { + const rect = cell.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }) ?? table; + const dispatch = (target: Element, type: string, options: any = {}) => { + const event: any = { + type, target, defaultPrevented: false, stopped: false, immediate: false, + preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, + stopImmediatePropagation() { this.stopped = true; this.immediate = true; }, + ...options + }; + const invoke = (el: Element, capture: boolean) => { + for (const listener of el.listeners.get(type) || []) { + if (listener.capture === capture && !event.immediate) { listener.callback(event); } + } + }; + invoke(document, true); + if (!event.stopped) { invoke(target, false); } + for (let parent = target.parentElement; parent && !event.stopped; parent = parent.parentElement) { invoke(parent, false); } + return event; + }; + return { context, cell, byId, document, table, messages, dispatch, select: (startRow: number, startCol: number, endRow = startRow, endCol = startCol) => { + vm.runInContext(`selectRange({ row: ${startRow}, col: ${startCol} }, { row: ${endRow}, col: ${endCol} }); anchorCell = currentSelection[0];`, context); + }}; +} diff --git a/src/test/column-sizing.test.ts b/src/test/column-sizing.test.ts new file mode 100644 index 0000000..b119796 --- /dev/null +++ b/src/test/column-sizing.test.ts @@ -0,0 +1,114 @@ +import assert from 'assert'; +import Module from 'module'; +import vm from 'vm'; +import { describe, it } from 'node:test'; +import { createWebview } from '../test-support/sizing-webview-harness'; + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'vscode') { return { window: { activeColorTheme: { kind: 1 } }, ColorThemeKind: { Dark: 1 } }; } + return originalRequire.apply(this, arguments as any); +}; +import { CsvEditorProvider } from '../CsvEditorProvider'; +Module.prototype.require = originalRequire; + +describe('Column sizing behavior', () => { + it('measures longest physical line across LF, CRLF and CR without counting separators', () => { + assert.deepStrictEqual(CsvEditorProvider.__test.computeColumnWidths([ + ['short\nlongest', 'a\r\nb\r\nc', '123\r45', ''], + ['x\nx\nx\nx\nx\nx\nx\nx', 'header', '1', 'tiny'] + ]), [7, 6, 3, 4]); + }); + + it('stores validated manual widths per URI and removes only the reset file', async () => { + const store: Record = {}; + const context = { workspaceState: { + get: (key: string, fallback: any) => store[key] ?? fallback, + update: async (key: string, value: any) => { store[key] = value; } + }} as any; + const first = { toString: () => 'file:///one.csv' } as any; + const second = { toString: () => 'file:///two.tsv' } as any; + await CsvEditorProvider.setColumnSizesForUri(context, first, { '0': 180.3, '-1': 100, invalid: 200, '1': 39, '2': Infinity }); + await CsvEditorProvider.setColumnSizesForUri(context, second, { '1': 220 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), { '0': 180 }); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + await CsvEditorProvider.setColumnSizesForUri(context, first, {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, first), {}); + assert.deepStrictEqual(CsvEditorProvider.getColumnSizesForUri(context, second), { '1': 220 }); + }); + + it('restores durable widths when a fresh webview opens, including newly mounted chunks', () => { + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(cell.style.width, '210px'); + assert.strictEqual(cell.style.boxSizing, 'border-box'); + const chunkCell = view.cell(1001, 0, 'later chunk'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(chunkCell.style.minWidth, '210px'); + assert.strictEqual(chunkCell.style.boxSizing, 'border-box'); + vm.runInContext('resetColumnWidth(0);', view.context); + assert.strictEqual(cell.style.boxSizing, ''); + assert.strictEqual(chunkCell.style.boxSizing, ''); + }); + + it('honors an explicit webview reset over durable values and restores automatic CSS', () => { + const reset = createWebview({ state: { columnSizes: {} }, persistedColumns: { '0': 210 } }); + const untouched = reset.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells();', reset.context); + assert.strictEqual(untouched.style.minWidth, '8ch'); + const view = createWebview({ persistedColumns: { '0': 210 } }); + const cell = view.cell(1, 0, 'initial'); + vm.runInContext('applySizeStateToRenderedCells(); resetColumnWidth(0); persistColumnSizes();', view.context); + assert.strictEqual(cell.style.width, ''); + assert.strictEqual(cell.style.minWidth, '8ch'); + assert.strictEqual(cell.style.maxWidth, '100ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); + + it("restores each cell's original box sizing after repeated resizes", () => { + const view = createWebview(); + const header = view.cell(0, 0, 'header', true); + const body = view.cell(1, 0, 'body'); + header.style.boxSizing = 'content-box'; + body.style.boxSizing = 'border-box'; + vm.runInContext('applyColumnWidth(0, 140); applyColumnWidth(0, 180);', view.context); + assert.strictEqual(header.style.boxSizing, 'border-box'); + assert.strictEqual(body.style.boxSizing, 'border-box'); + vm.runInContext('resetColumnWidth(0);', view.context); + assert.strictEqual(header.style.boxSizing, 'content-box'); + assert.strictEqual(body.style.boxSizing, 'border-box'); + assert.strictEqual(header.style.minWidth, '8ch'); + assert.strictEqual(body.style.maxWidth, '100ch'); + }); + + it('starts and saves a resize from both sides of a boundary with headers on or off', () => { + for (const header of [true, false]) { + for (const side of ['left', 'right']) { + const view = createWebview({ header }); + const first = view.cell(header ? 0 : 1, 0, 'one', header); + const second = view.cell(header ? 0 : 1, 1, 'two', header); + const target = side === 'left' ? first : second; + const clientX = side === 'left' ? 97 : 103; + const clientY = header ? 15 : 45; + const event = view.dispatch(target, 'mousedown', { button: 0, clientX, clientY }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + view.dispatch(target, 'mousemove', { clientX: clientX + 50, clientY }); + view.dispatch(target, 'mouseup'); + assert.strictEqual(first.style.width, '150px'); + assert.strictEqual(second.style.width, ''); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: { '0': 150 } }]); + } + } + }); + + it('double-click resets a resized column and persists the reset', () => { + const view = createWebview(); + const header = view.cell(0, 0, 'one', true); + vm.runInContext('applyColumnWidth(0, 230);', view.context); + view.dispatch(header, 'dblclick', { clientX: 96, clientY: 15 }); + assert.strictEqual(header.style.minWidth, '8ch'); + assert.deepStrictEqual(view.messages, [{ type: 'columnSizesChanged', columnSizes: {} }]); + }); +}); diff --git a/src/test/resize-hit-testing.test.ts b/src/test/resize-hit-testing.test.ts new file mode 100644 index 0000000..85349e5 --- /dev/null +++ b/src/test/resize-hit-testing.test.ts @@ -0,0 +1,218 @@ +import assert from 'assert'; +import { describe, it } from 'node:test'; +import { createEditorHarness, createWebviewDom } from './edit-integrity-harness'; + +type Rect = { left: number; right: number; top: number; bottom: number; width: number; height: number }; + +async function fixture(header = true, widths = [100, 17, 100], separator = ',') { + const host = await createEditorHarness({ rows: [['name', '', 'value'], ['one', '', 'three']], header, separator }); + const view = createWebviewDom(host.panel.webview.html); + const { window } = view; + const table = window.document.querySelector('table'); + const cells = Array.from(table.querySelectorAll('[data-row][data-col]')) as any[]; + const fixed = new Map(); + let probes = 0; + let border: number | undefined; + let cover: any; + const rect = (cell: any): Rect => { + if (fixed.has(cell)) { return fixed.get(cell)!; } + const row = Number(cell.dataset.row), column = Number(cell.dataset.col); + const widthOf = (col: number) => Number.parseFloat(view.cell(row, col)?.style.width) || widths[col]; + const left = column < 0 ? 0 : 30 + widths.slice(0, column).reduce((sum, _width, col) => sum + widthOf(col), 0); + const width = column < 0 ? 30 : widthOf(column); + return { left, right: left + width, top: row * 30, bottom: row * 30 + 30, width, height: 30 }; + }; + for (const cell of cells) { cell.getBoundingClientRect = () => rect(cell); } + window.document.elementFromPoint = (x: number, y: number) => { + probes++; + const contains = (cell: any) => { + const box = rect(cell); + return box.width > 0 && box.height > 0 && x >= box.left && x <= box.right && y >= box.top && y <= box.bottom; + }; + if (cover && contains(cover)) { return cover; } + const hit = cells.slice().reverse().find(contains); + if (border !== undefined && Math.abs(x - border) < 0.2) { return hit?.parentElement ?? table; } + return hit ?? table; + }; + const mouse = (target: any, type: string, x: number, y = 15) => { + const event = new window.MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: y }); + target.dispatchEvent(event); + return event; + }; + return { ...view, table, fixed, rect, mouse, setBorder: (x: number) => { border = x; }, setCover: (cell: any) => { cover = cell; }, get probes() { return probes; } }; +} + +describe('Resize boundary event hit testing', () => { + for (const header of [true, false]) { + for (const separator of [',', '\t']) { + it(`leaves a blank 17px cell center selectable and editable (header ${header}, ${JSON.stringify(separator)})`, async () => { + const view = await fixture(header, [100, 17, 100], separator); + try { + const cell = view.cell(0, 1); + const x = view.rect(cell).left + 8.5; + view.mouse(cell, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, ''); + view.mouse(cell, 'mousedown', x); + view.mouse(cell, 'mouseup', x); + assert.ok(cell.classList.contains('selected')); + view.mouse(cell, 'dblclick', x); + assert.strictEqual(cell.getAttribute('contenteditable'), 'true'); + assert.ok(!view.messages.some(message => message.type === 'columnSizesChanged')); + } finally { view.close(); } + }); + } + } + + it('shrinks both boundaries further on 8px cells while preserving their center', async () => { + const view = await fixture(false, [100, 8, 100]); + try { + const cell = view.cell(0, 1); + for (const x of [132.1, 134, 135.9]) { + view.mouse(cell, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, ''); + } + for (const x of [130, 131.9, 136.1, 138]) { + view.mouse(cell, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + } + } finally { view.close(); } + }); + + for (const targetKind of ['row', 'table', 'previous', 'next']) { + it(`shares hover/drag/reset at a fractional border targeted as ${targetKind}`, async () => { + const view = await fixture(false); + try { + const first = view.cell(0, 0), next = view.cell(0, 1); + view.fixed.set(first, { left: 30, right: 130.25, top: 0, bottom: 30, width: 100.25, height: 30 }); + view.fixed.set(next, { left: 130.75, right: 147.75, top: 0, bottom: 30, width: 17, height: 30 }); + view.setBorder(130.5); + const target = targetKind === 'row' ? first.parentElement : targetKind === 'table' ? view.table : targetKind === 'previous' ? first : next; + view.mouse(target, 'mousemove', 130.5); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + assert.strictEqual(view.mouse(target, 'mousedown', 130.5).defaultPrevented, true); + view.mouse(target, 'mousemove', 150.5); + view.mouse(target, 'mouseup', 150.5); + assert.strictEqual(first.style.width, '120px'); + assert.strictEqual(first.style.boxSizing, 'border-box'); + assert.strictEqual(next.style.width, ''); + assert.deepStrictEqual({ ...view.messages.filter(message => message.type === 'columnSizesChanged').at(-1).columnSizes }, { '0': 120 }); + assert.strictEqual(view.mouse(target, 'dblclick', 130.5).defaultPrevented, true); + assert.strictEqual(first.style.width, ''); + assert.strictEqual(first.style.boxSizing, ''); + assert.deepStrictEqual({ ...view.messages.filter(message => message.type === 'columnSizesChanged').at(-1).columnSizes }, {}); + } finally { view.close(); } + }); + } + + it('resolves the painted frozen neighbor rather than an occluded logical predecessor', async () => { + const view = await fixture(false); + try { + const frozen = view.cell(0, 0), covered = view.cell(0, 1), visible = view.cell(0, 2); + view.fixed.set(frozen, { left: 100, right: 150, top: 0, bottom: 30, width: 50, height: 30 }); + view.fixed.set(covered, { left: 130, right: 147, top: 0, bottom: 30, width: 17, height: 30 }); + view.fixed.set(visible, { left: 150, right: 250, top: 0, bottom: 30, width: 100, height: 30 }); + view.setCover(frozen); + view.mouse(covered, 'mousemove', 143); + assert.strictEqual(view.table.style.cursor, '', 'covered source boundary cannot resize'); + view.mouse(visible, 'mousemove', 150); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + view.mouse(visible, 'mousedown', 150); + view.mouse(visible, 'mousemove', 170); + view.mouse(visible, 'mouseup', 170); + assert.strictEqual(frozen.style.width, '70px'); + assert.strictEqual(covered.style.width, ''); + assert.deepStrictEqual({ ...view.messages.filter(message => message.type === 'columnSizesChanged').at(-1).columnSizes }, { '0': 70 }); + } finally { view.close(); } + }); + + for (const x of [149, 151]) { + it(`shares hover/drag/reset on either side of a partially occluded frozen seam at ${x}`, async () => { + const view = await fixture(false); + try { + const frozen = view.cell(0, 0), covered = view.cell(0, 1), visible = view.cell(0, 2); + view.fixed.set(frozen, { left: 100, right: 150, top: 0, bottom: 30, width: 50, height: 30 }); + view.fixed.set(covered, { left: 120, right: 145, top: 0, bottom: 30, width: 25, height: 30 }); + view.fixed.set(visible, { left: 140, right: 250, top: 0, bottom: 30, width: 110, height: 30 }); + view.setCover(frozen); + const target = x < 150 ? frozen : visible; + view.mouse(target, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + assert.strictEqual(view.mouse(target, 'mousedown', x).defaultPrevented, true); + view.mouse(target, 'mousemove', x + 20); + view.mouse(target, 'mouseup', x + 20); + assert.strictEqual(frozen.style.width, '70px'); + assert.strictEqual(covered.style.width, ''); + assert.strictEqual(visible.style.width, ''); + assert.deepStrictEqual({ ...view.messages.filter(message => message.type === 'columnSizesChanged').at(-1).columnSizes }, { '0': 70 }); + assert.strictEqual(view.mouse(target, 'dblclick', x).defaultPrevented, true); + assert.strictEqual(frozen.style.width, ''); + assert.strictEqual(frozen.style.boxSizing, ''); + assert.deepStrictEqual({ ...view.messages.filter(message => message.type === 'columnSizesChanged').at(-1).columnSizes }, {}); + } finally { view.close(); } + }); + } + + it('keeps the painted center of a partially covered narrow cell selectable', async () => { + const view = await fixture(false); + try { + const frozen = view.cell(0, 0), covered = view.cell(0, 1), visible = view.cell(0, 2); + view.fixed.set(frozen, { left: 100, right: 150, top: 0, bottom: 30, width: 50, height: 30 }); + view.fixed.set(covered, { left: 120, right: 145, top: 0, bottom: 30, width: 25, height: 30 }); + view.fixed.set(visible, { left: 140, right: 158, top: 0, bottom: 30, width: 18, height: 30 }); + view.setCover(frozen); + for (const x of [152.1, 154, 155.9]) { + view.mouse(visible, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, ''); + } + view.mouse(visible, 'mousedown', 154); + view.mouse(visible, 'mouseup', 154); + assert.ok(visible.classList.contains('selected')); + assert.ok(!view.messages.some(message => message.type === 'columnSizesChanged')); + for (const x of [150, 151.9, 156.1, 158]) { + view.mouse(x < 150 ? frozen : visible, 'mousemove', x); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + } + } finally { view.close(); } + }); + + it('ignores links, controls, hidden columns and active editors', async () => { + const view = await fixture(false); + try { + const cell = view.cell(0, 0); + for (const tag of ['a', 'button', 'input']) { + const control = view.window.document.createElement(tag); + cell.appendChild(control); + view.mouse(control, 'mousemove', 130); + assert.strictEqual(view.table.style.cursor, ''); + control.remove(); + } + const hidden = view.cell(0, 1); + view.fixed.set(hidden, { left: 140, right: 140, top: 0, bottom: 30, width: 0, height: 30 }); + view.mouse(hidden, 'mousemove', 140); + assert.strictEqual(view.table.style.cursor, ''); + view.start(cell); + view.mouse(cell, 'mousemove', 130); + assert.strictEqual(view.table.style.cursor, ''); + } finally { view.close(); } + }); + + it('keeps hover work bounded to painted points and leaves row-index resizing unchanged', async () => { + const view = await fixture(false); + try { + const cell = view.cell(0, 1); + const query = view.table.querySelectorAll; + view.table.querySelectorAll = () => { throw new Error('hover must not scan rendered cells'); }; + view.mouse(cell, 'mousemove', 130); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + assert.ok(view.probes <= 10); + view.table.querySelectorAll = query; + const index = view.cell(0, -1); + view.mouse(index, 'mousemove', 15, 29); + assert.strictEqual(view.table.style.cursor, 'row-resize'); + view.mouse(index, 'mousedown', 15, 29); + view.mouse(index, 'mousemove', 15, 39); + view.mouse(index, 'mouseup', 15, 39); + assert.strictEqual(index.style.height, '40px'); + } finally { view.close(); } + }); +}); diff --git a/src/test/webview-size-persistence.test.ts b/src/test/webview-size-persistence.test.ts index 4399f1e..3ecdddd 100644 --- a/src/test/webview-size-persistence.test.ts +++ b/src/test/webview-size-persistence.test.ts @@ -13,7 +13,7 @@ describe('Webview size persistence', () => { }); it('restores and reapplies size state after render/chunk loads', () => { - assert.ok(source.includes('columnSizeState = normalizeSizeState(st.columnSizes, 40);')); + assert.ok(source.includes('columnSizeState = normalizeSizeState(st.columnSizes ?? persistedColumnSizes, 40);')); assert.ok(source.includes('rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight());')); assert.ok(source.includes('setZoomScale(restoredZoom ?? 1, false);')); assert.ok(source.includes('applySizeStateToRenderedCells();'));