From 2dadc9c3eb69769f41bab5630fc73b854ec09f18 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 00:07:37 -0400 Subject: [PATCH 1/4] Make multiline and manual column widths predictable (#87, #98, #101) --- README.md | 2 +- media/main.js | 51 ++++--- src/CsvEditorProvider.ts | 48 ++++++- src/test-support/sizing-webview-harness.ts | 147 +++++++++++++++++++++ src/test/column-sizing.test.ts | 92 +++++++++++++ src/test/webview-size-persistence.test.ts | 2 +- 6 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 src/test-support/sizing-webview-harness.ts create mode 100644 src/test/column-sizing.test.ts diff --git a/README.md b/README.md index 9dc6cd7..8f7a614 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 within 10 pixels on either side of a column boundary to resize; 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/media/main.js b/media/main.js index 8b4678a..2b204c5 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 = 10; 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,22 @@ 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 }); + } + 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 +166,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) { @@ -561,13 +574,18 @@ const getRowDropTarget = clientY => { }; 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 }; + const coords = getDataCellCoords(target); + if (coords) { + const rect = target.getBoundingClientRect(); + const rightDistance = rect.right - e.clientX; + if (rightDistance >= 0 && rightDistance <= COLUMN_RESIZE_HANDLE_PX) { + return { axis: 'column', index: coords.col, rect }; + } + const leftDistance = e.clientX - rect.left; + if (coords.col > 0 && leftDistance >= 0 && leftDistance <= COLUMN_RESIZE_HANDLE_PX) { + const previous = getRenderedCellByCoords(coords.row, coords.col - 1); + if (previous) { + return { axis: 'column', index: coords.col - 1, rect: previous.getBoundingClientRect() }; } } } @@ -587,17 +605,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 +715,7 @@ const onGlobalDragMove = e => { }; const onGlobalDragEnd = () => { if (resizeState) { + if (resizeState.axis === 'column') persistColumnSizes(); resizeState = null; table.style.cursor = ''; persistState(); @@ -2022,6 +2038,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..82c9558 --- /dev/null +++ b/src/test-support/sizing-webview-harness.ts @@ -0,0 +1,147 @@ +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; } + 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'); + 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.setAttribute('data-row', String(row)); + el.setAttribute('data-col', String(col)); + el.textContent = value; + (header ? head : table).appendChild(el); + return el; + }; + 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..833aa42 --- /dev/null +++ b/src/test/column-sizing.test.ts @@ -0,0 +1,92 @@ +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'); + const chunkCell = view.cell(1001, 0, 'later chunk'); + vm.runInContext('applySizeStateToRenderedCells();', view.context); + assert.strictEqual(chunkCell.style.minWidth, '210px'); + }); + + 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('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' ? 91 : 109; + const event = view.dispatch(target, 'mousedown', { button: 0, clientX, clientY: 15 }); + assert.strictEqual(event.defaultPrevented, true); + assert.strictEqual(view.table.style.cursor, 'col-resize'); + view.dispatch(target, 'mousemove', { clientX: clientX + 50, clientY: 15 }); + 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/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();')); From cd05843a8b7e1a3218cf060517cee896374d6e71 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:41:32 -0400 Subject: [PATCH 2/4] Fix narrow column resize boundaries and drag sizing --- README.md | 2 +- media/main.js | 77 +++++++--- src/test-support/sizing-webview-harness.ts | 18 ++- src/test/column-sizing.test.ts | 28 +++- src/test/resize-hit-testing.test.ts | 168 +++++++++++++++++++++ 5 files changed, 262 insertions(+), 31 deletions(-) create mode 100644 src/test/resize-hit-testing.test.ts diff --git a/README.md b/README.md index f79fb14..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 widths follow the longest line in multiline cells. Drag within 10 pixels on either side of a column boundary to resize; 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. +- **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/media/main.js b/media/main.js index 2b204c5..58b79ab 100644 --- a/media/main.js +++ b/media/main.js @@ -33,7 +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 = 10; +const COLUMN_RESIZE_HANDLE_PX = 4; let resizeState = null; let reorderState = null; @@ -67,8 +67,9 @@ const normalizeSizeState = (raw, minSize) => { const setRenderedColumnWidth = (cell, px) => { if (!automaticColumnSizes.has(cell)) { - automaticColumnSizes.set(cell, { width: cell.style.width, minWidth: cell.style.minWidth, maxWidth: cell.style.maxWidth }); + 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`; @@ -573,22 +574,54 @@ const getRowDropTarget = clientY => { return { beforeIndex, indicatorY }; }; const getResizeEdgeInfo = (target, e) => { - if (!target) return null; - const coords = getDataCellCoords(target); - if (coords) { + 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); const rect = target.getBoundingClientRect(); - const rightDistance = rect.right - e.clientX; - if (rightDistance >= 0 && rightDistance <= COLUMN_RESIZE_HANDLE_PX) { - return { axis: 'column', index: coords.col, rect }; - } - const leftDistance = e.clientX - rect.left; - if (coords.col > 0 && leftDistance >= 0 && leftDistance <= COLUMN_RESIZE_HANDLE_PX) { - const previous = getRenderedCellByCoords(coords.row, coords.col - 1); - if (previous) { - return { axis: 'column', index: coords.col - 1, rect: previous.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 contiguous = nextRect && nextRect.width > 0 && Math.abs(nextRect.left - rect.right) <= 1.5; + const boundary = contiguous ? (rect.right + nextRect.left) / 2 : rect.right; + // Each side uses at most a quarter of a narrow cell, leaving its center usable. + const band = Math.min(COLUMN_RESIZE_HANDLE_PX, rect.width / 4, contiguous ? nextRect.width / 4 : Infinity); + 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)) { @@ -785,6 +818,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 @@ -803,10 +840,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; @@ -913,10 +946,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'; diff --git a/src/test-support/sizing-webview-harness.ts b/src/test-support/sizing-webview-harness.ts index 82c9558..c374f52 100644 --- a/src/test-support/sizing-webview-harness.ts +++ b/src/test-support/sizing-webview-harness.ts @@ -21,6 +21,8 @@ export function createWebview(options: { state?: any; persistedColumns?: Record< 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)), @@ -90,6 +92,7 @@ export function createWebview(options: { state?: any; persistedColumns?: Record< 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 || {}); @@ -115,13 +118,23 @@ export function createWebview(options: { state?: any; persistedColumns?: Record< 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.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; - (header ? head : table).appendChild(el); + 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, @@ -144,4 +157,3 @@ export function createWebview(options: { state?: any; persistedColumns?: Record< 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 index 833aa42..b119796 100644 --- a/src/test/column-sizing.test.ts +++ b/src/test/column-sizing.test.ts @@ -42,9 +42,14 @@ describe('Column sizing behavior', () => { 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', () => { @@ -61,6 +66,22 @@ describe('Column sizing behavior', () => { 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']) { @@ -68,11 +89,12 @@ describe('Column sizing behavior', () => { 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' ? 91 : 109; - const event = view.dispatch(target, 'mousedown', { button: 0, clientX, clientY: 15 }); + 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: 15 }); + view.dispatch(target, 'mousemove', { clientX: clientX + 50, clientY }); view.dispatch(target, 'mouseup'); assert.strictEqual(first.style.width, '150px'); assert.strictEqual(second.style.width, ''); diff --git a/src/test/resize-hit-testing.test.ts b/src/test/resize-hit-testing.test.ts new file mode 100644 index 0000000..c041ad4 --- /dev/null +++ b/src/test/resize-hit-testing.test.ts @@ -0,0 +1,168 @@ +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', 147); + 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(); } + }); + + 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: 130, right: 130, top: 0, bottom: 30, width: 0, height: 30 }); + view.mouse(hidden, 'mousemove', 130); + 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(); } + }); +}); From 0120c2a78f5af75c745c032dc1451fd24a08c58c Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:44:09 -0400 Subject: [PATCH 3/4] Handle partially covered frozen resize boundaries --- media/main.js | 12 +++++-- src/test/resize-hit-testing.test.ts | 56 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/media/main.js b/media/main.js index 58b79ab..667db2b 100644 --- a/media/main.js +++ b/media/main.js @@ -585,6 +585,8 @@ const getResizeEdgeInfo = (target, e) => { 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. @@ -611,10 +613,14 @@ const getResizeEdgeInfo = (target, e) => { 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 contiguous = nextRect && nextRect.width > 0 && Math.abs(nextRect.left - rect.right) <= 1.5; + 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; - // Each side uses at most a quarter of a narrow cell, leaving its center usable. - const band = Math.min(COLUMN_RESIZE_HANDLE_PX, rect.width / 4, contiguous ? nextRect.width / 4 : Infinity); + 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; diff --git a/src/test/resize-hit-testing.test.ts b/src/test/resize-hit-testing.test.ts index c041ad4..85349e5 100644 --- a/src/test/resize-hit-testing.test.ts +++ b/src/test/resize-hit-testing.test.ts @@ -112,7 +112,7 @@ describe('Resize boundary event hit testing', () => { 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', 147); + 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'); @@ -125,6 +125,56 @@ describe('Resize boundary event hit testing', () => { } 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 { @@ -137,8 +187,8 @@ describe('Resize boundary event hit testing', () => { control.remove(); } const hidden = view.cell(0, 1); - view.fixed.set(hidden, { left: 130, right: 130, top: 0, bottom: 30, width: 0, height: 30 }); - view.mouse(hidden, 'mousemove', 130); + 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); From 8e5475c6f17e4f769a07559482941c219fbf4874 Mon Sep 17 00:00:00 2001 From: Jona Date: Sat, 17 Oct 2026 02:45:18 -0400 Subject: [PATCH 4/4] Add narrow-column resize verification screenshot --- images/resize-hit-area.jpg | Bin 0 -> 4764 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/resize-hit-area.jpg diff --git a/images/resize-hit-area.jpg b/images/resize-hit-area.jpg new file mode 100644 index 0000000000000000000000000000000000000000..43bfb24efcbd56b7f1a547b6bbc93ce778839f1e GIT binary patch literal 4764 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 Bwh{mU literal 0 HcmV?d00001