Skip to content

Commit 029f6ab

Browse files
committed
feat: add Ctrl/Cmd zoom controls (wheel and +/-/0) with invert option
- add csv.mouseWheelZoom and csv.mouseWheelZoomInvert settings - support Ctrl/Cmd + wheel, Ctrl/Cmd + +, Ctrl/Cmd + -, Ctrl/Cmd + 0 in webview - persist zoom level and scale row minimum height with zoom - update README and add webview zoom tests
1 parent 310bfd5 commit 029f6ab

6 files changed

Lines changed: 135 additions & 11 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Working with CSV files shouldn’t be a chore. With CSV, you get:
3838
- **Edit Empty CSVs:** Create or open an empty CSV file and start typing immediately.
3939
- **Column Sorting:** Right-click a header and choose A–Z or Z–A.
4040
- **Custom Font Controls:** Choose a font family and optional font-size override, or inherit VS Code defaults.
41+
- **In-View Zoom:** Use `Ctrl/Cmd + Mouse Wheel` or `Ctrl/Cmd + +/-/0` to zoom the CSV view without changing global editor zoom.
4142
- **Find & Replace Overlay:** Built-in find/replace bar with match options (case, whole-word, regex), keyboard navigation, and single/all replace actions across the full file (including chunked rows).
4243
- **Multiline Cell Display:** Cells with embedded newlines render as wrapped multi-line content (with preserved line breaks and matching row height).
4344
- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser.
@@ -99,6 +100,8 @@ Global (Settings UI or `settings.json`):
99100
- `csv.enabled` (boolean, default `true`): Enable/disable the custom editor.
100101
- `csv.fontFamily` (string, default empty): Override font family; falls back to `editor.fontFamily`.
101102
- `csv.fontSize` (number, default `0`): Override font size in px; set to `0` to inherit `editor.fontSize`.
103+
- `csv.mouseWheelZoom` (boolean, default `true`): Enable `Ctrl/Cmd + Mouse Wheel` zooming in the CSV editor.
104+
- `csv.mouseWheelZoomInvert` (boolean, default `false`): Invert the `Ctrl/Cmd + Mouse Wheel` zoom direction.
102105
- `csv.cellPadding` (number, default `4`): Vertical cell padding in pixels.
103106
- `csv.columnColorMode` (string, default `type`): `type` keeps CSV’s type-based column colors; `theme` uses your theme foreground color for all columns.
104107
- `csv.columnColorPalette` (string, default `default`): Type-color palette when `csv.columnColorMode` is `type`. `cool` biases colors toward greens/blues; `warm` biases colors toward oranges/reds.
@@ -133,6 +136,7 @@ Per-file (stored by the extension; set via commands):
133136
- Global:
134137
- Copy selection: `Ctrl/Cmd + C`
135138
- Paste selection: `Ctrl/Cmd + V` (selection mode). Pasting a single value into a selected rectangle fills that rectangle.
139+
- Zoom in/out/reset: `Ctrl/Cmd + +`, `Ctrl/Cmd + -`, `Ctrl/Cmd + 0` (also `Ctrl/Cmd + Mouse Wheel`)
136140
- Find: `Ctrl/Cmd + F`
137141
- Replace: `Ctrl/Cmd + H`
138142
- Next/Previous match: `F3` / `Shift + F3` (also `Enter` / `Shift + Enter` in the Find box)

media/main.js

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ const parsePositiveNumber = value => {
1212
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
1313
return parsed;
1414
};
15+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
1516
const configuredFontSizePx = parsePositiveNumber(root?.dataset?.fontsize);
1617
const computedFontSizePx = parsePositiveNumber(window.getComputedStyle(document.body).fontSize);
1718
const BASE_FONT_SIZE_PX = configuredFontSizePx ?? computedFontSizePx ?? 14;
18-
const MIN_ROW_HEIGHT = Math.max(22, Math.round(BASE_FONT_SIZE_PX * 1.6));
19+
const MOUSE_WHEEL_ZOOM_ENABLED = root?.dataset?.wheelzoomenabled !== '0';
20+
const MOUSE_WHEEL_ZOOM_INVERTED = root?.dataset?.wheelzoominvert === '1';
21+
const ZOOM_STEP = 0.1;
22+
const ZOOM_MIN = 0.5;
23+
const ZOOM_MAX = 3.0;
24+
let zoomScale = 1;
25+
const getMinRowHeight = () => Math.max(22, Math.round(BASE_FONT_SIZE_PX * zoomScale * 1.6));
1926

2027
let lastContextIsHeader = false; // remembers whether we right-clicked a <th>
2128
let isUpdating = false, isSelecting = false, anchorCell = null, rangeEndCell = null, currentSelection = [];
@@ -65,7 +72,7 @@ const applySizeStateToRenderedCells = () => {
6572
});
6673
}
6774
for (const [row, height] of Object.entries(rowSizeState)) {
68-
const px = Math.max(MIN_ROW_HEIGHT, Math.round(Number(height)));
75+
const px = Math.max(getMinRowHeight(), Math.round(Number(height)));
6976
table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => {
7077
cell.style.height = `${px}px`;
7178
cell.style.minHeight = `${px}px`;
@@ -83,6 +90,46 @@ const getFirstDataRow = () => {
8390
return Number.isFinite(min) ? min : 0;
8491
};
8592

93+
const setZoomScale = (nextScale, persist = true) => {
94+
const normalized = clamp(Math.round(nextScale * 100) / 100, ZOOM_MIN, ZOOM_MAX);
95+
if (Math.abs(normalized - zoomScale) < 0.001) {
96+
return false;
97+
}
98+
zoomScale = normalized;
99+
document.body.style.fontSize = `${Math.max(1, BASE_FONT_SIZE_PX * zoomScale)}px`;
100+
applySizeStateToRenderedCells();
101+
if (persist) {
102+
persistState();
103+
}
104+
return true;
105+
};
106+
const zoomIn = () => setZoomScale(zoomScale + ZOOM_STEP);
107+
const zoomOut = () => setZoomScale(zoomScale - ZOOM_STEP);
108+
const resetZoom = () => setZoomScale(1);
109+
const isZoomModifier = e => (e.ctrlKey || e.metaKey) && !e.altKey;
110+
const isZoomInShortcut = e => e.code === 'NumpadAdd' || e.key === '+' || e.key === '=';
111+
const isZoomOutShortcut = e => e.code === 'NumpadSubtract' || e.key === '-' || e.key === '_';
112+
const isZoomResetShortcut = e => e.key === '0';
113+
const maybeHandleZoomShortcut = e => {
114+
if (!isZoomModifier(e)) return false;
115+
if (isZoomInShortcut(e)) {
116+
e.preventDefault();
117+
zoomIn();
118+
return true;
119+
}
120+
if (isZoomOutShortcut(e)) {
121+
e.preventDefault();
122+
zoomOut();
123+
return true;
124+
}
125+
if (isZoomResetShortcut(e)) {
126+
e.preventDefault();
127+
resetZoom();
128+
return true;
129+
}
130+
return false;
131+
};
132+
86133
// Persist/restore view state (scroll + selection) across webview reloads
87134
const persistState = () => {
88135
try {
@@ -95,7 +142,8 @@ const persistState = () => {
95142
anchorRow: anchor ? anchor.row : undefined,
96143
anchorCol: anchor ? anchor.col : undefined,
97144
columnSizes: { ...columnSizeState },
98-
rowSizes: { ...rowSizeState }
145+
rowSizes: { ...rowSizeState },
146+
zoomScale
99147
};
100148
vscode.setState(nextState);
101149
} catch {}
@@ -104,8 +152,10 @@ const persistState = () => {
104152
const restoreState = () => {
105153
try {
106154
const st = vscode.getState() || {};
155+
const restoredZoom = parsePositiveNumber(st.zoomScale);
156+
setZoomScale(restoredZoom ?? 1, false);
107157
columnSizeState = normalizeSizeState(st.columnSizes, 40);
108-
rowSizeState = normalizeSizeState(st.rowSizes, MIN_ROW_HEIGHT);
158+
rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight());
109159
applySizeStateToRenderedCells();
110160
if (typeof st.scrollX === 'number' && scrollContainer) {
111161
scrollContainer.scrollLeft = st.scrollX;
@@ -302,6 +352,21 @@ document.addEventListener('visibilitychange', () => {
302352
}
303353
});
304354

355+
const handleZoomWheel = e => {
356+
if (!MOUSE_WHEEL_ZOOM_ENABLED) return;
357+
if (!isZoomModifier(e)) return;
358+
if (Math.abs(e.deltaY) < 0.1) return;
359+
e.preventDefault();
360+
const naturalDirection = e.deltaY < 0 ? 1 : -1;
361+
const direction = MOUSE_WHEEL_ZOOM_INVERTED ? -naturalDirection : naturalDirection;
362+
if (direction > 0) {
363+
zoomIn();
364+
} else {
365+
zoomOut();
366+
}
367+
};
368+
window.addEventListener('wheel', handleZoomWheel, { passive: false });
369+
305370
const hasHeader = document.querySelector('thead') !== null;
306371
const getCellCoords = cell => ({ row: parseInt(cell.getAttribute('data-row')), col: parseInt(cell.getAttribute('data-col')) });
307372
const clearSelection = () => { currentSelection.forEach(c => c.classList.remove('selected')); currentSelection = []; };
@@ -533,7 +598,7 @@ const resetColumnWidth = col => {
533598
});
534599
};
535600
const applyRowHeight = (row, heightPx) => {
536-
const height = Math.max(MIN_ROW_HEIGHT, Math.round(heightPx));
601+
const height = Math.max(getMinRowHeight(), Math.round(heightPx));
537602
rowSizeState[String(row)] = height;
538603
table.querySelectorAll(`[data-row="${row}"]`).forEach(cell => {
539604
cell.style.height = `${height}px`;
@@ -1428,6 +1493,9 @@ document.addEventListener('keydown', e => {
14281493
}, true);
14291494

14301495
document.addEventListener('keydown', e => {
1496+
if (maybeHandleZoomShortcut(e)) {
1497+
return;
1498+
}
14311499
const key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
14321500
if ((e.ctrlKey || e.metaKey) && key === 'f') {
14331501
e.preventDefault();

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@
123123
"description": "Font size in pixels for the CSV custom editor. Set to 0 to inherit 'editor.fontSize'.",
124124
"scope": "application"
125125
},
126+
"csv.mouseWheelZoom": {
127+
"type": "boolean",
128+
"default": true,
129+
"description": "Enable Ctrl/Cmd + mouse wheel zoom in the CSV editor.",
130+
"scope": "application"
131+
},
132+
"csv.mouseWheelZoomInvert": {
133+
"type": "boolean",
134+
"default": false,
135+
"description": "Invert the Ctrl/Cmd + mouse wheel zoom direction in the CSV editor.",
136+
"scope": "application"
137+
},
126138
"csv.cellPadding": {
127139
"type": "number",
128140
"default": 4,

src/CsvEditorProvider.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,8 @@ class CsvEditorController {
13961396
);
13971397
const columnColorPalette = config.get<string>('columnColorPalette', 'default');
13981398
const showTrailingEmptyRow = config.get<boolean>('showTrailingEmptyRow', true);
1399+
const mouseWheelZoomEnabled = config.get<boolean>('mouseWheelZoom', true);
1400+
const mouseWheelZoomInvert = config.get<boolean>('mouseWheelZoomInvert', false);
13991401

14001402
const { tableHtml, chunksJson, colorCss, nextChunkStart, hasRemoteChunks, chunkState } =
14011403
this.generateTableAndChunks(
@@ -1424,7 +1426,9 @@ class CsvEditorController {
14241426
chunksJson,
14251427
extraColumnColorCss: colorCss,
14261428
nextChunkStart,
1427-
hasRemoteChunks
1429+
hasRemoteChunks,
1430+
mouseWheelZoomEnabled,
1431+
mouseWheelZoomInvert
14281432
});
14291433
}
14301434

@@ -1675,8 +1679,10 @@ class CsvEditorController {
16751679
extraColumnColorCss: string;
16761680
nextChunkStart: number;
16771681
hasRemoteChunks: boolean;
1682+
mouseWheelZoomEnabled: boolean;
1683+
mouseWheelZoomInvert: boolean;
16781684
}): string {
1679-
const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks } = args;
1685+
const { webview, nonce, fontFamily, fontSize, cellPadding, separator, tableHtml, chunksJson, extraColumnColorCss, nextChunkStart, hasRemoteChunks, mouseWheelZoomEnabled, mouseWheelZoomInvert } = args;
16801686
const isDark = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark;
16811687
// Build script URI using file path for compatibility (older APIs may lack Uri.joinPath)
16821688
const scriptUri = webview.asWebviewUri(
@@ -1888,7 +1894,7 @@ class CsvEditorController {
18881894
</style>
18891895
</head>
18901896
<body>
1891-
<div id="csv-root" class="table-container" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
1897+
<div id="csv-root" class="table-container" data-sepcode="${sepCode}" data-fontsize="${fontSize}" data-wheelzoomenabled="${mouseWheelZoomEnabled ? '1' : '0'}" data-wheelzoominvert="${mouseWheelZoomInvert ? '1' : '0'}" data-nextchunkstart="${nextChunkStart >= 0 ? nextChunkStart : ''}" data-hasmorechunks="${hasRemoteChunks ? '1' : '0'}">
18921898
${tableHtml}
18931899
</div>
18941900

src/test/webview-size-persistence.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,27 @@ describe('Webview size persistence', () => {
99
it('persists column and row sizes in webview state', () => {
1010
assert.ok(source.includes('columnSizes: { ...columnSizeState }'));
1111
assert.ok(source.includes('rowSizes: { ...rowSizeState }'));
12+
assert.ok(source.includes('zoomScale'));
1213
});
1314

1415
it('restores and reapplies size state after render/chunk loads', () => {
1516
assert.ok(source.includes('columnSizeState = normalizeSizeState(st.columnSizes, 40);'));
16-
assert.ok(source.includes('rowSizeState = normalizeSizeState(st.rowSizes, MIN_ROW_HEIGHT);'));
17+
assert.ok(source.includes('rowSizeState = normalizeSizeState(st.rowSizes, getMinRowHeight());'));
18+
assert.ok(source.includes('setZoomScale(restoredZoom ?? 1, false);'));
1719
assert.ok(source.includes('applySizeStateToRenderedCells();'));
1820
});
1921

2022
it('updates in-memory size maps when resizing', () => {
2123
assert.ok(source.includes('columnSizeState[String(col)] = width;'));
2224
assert.ok(source.includes('rowSizeState[String(row)] = height;'));
23-
assert.ok(source.includes('Math.max(MIN_ROW_HEIGHT, Math.round(heightPx))'));
25+
assert.ok(source.includes('Math.max(getMinRowHeight(), Math.round(heightPx))'));
2426
});
2527

2628
it('derives a dynamic minimum row height from configured font size', () => {
2729
assert.ok(source.includes('const BASE_FONT_SIZE_PX ='));
28-
assert.ok(source.includes('const MIN_ROW_HEIGHT = Math.max(22, Math.round(BASE_FONT_SIZE_PX * 1.6));'));
30+
assert.ok(source.includes('const ZOOM_MIN = 0.5;'));
31+
assert.ok(source.includes('const ZOOM_MAX = 3.0;'));
32+
assert.ok(source.includes('const getMinRowHeight = () => Math.max(22, Math.round(BASE_FONT_SIZE_PX * zoomScale * 1.6));'));
2933
});
3034

3135
it('removes size overrides from state when reset to defaults', () => {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import assert from 'assert';
2+
import { describe, it } from 'node:test';
3+
import fs from 'fs';
4+
import path from 'path';
5+
6+
describe('Webview zoom interactions', () => {
7+
const providerSource = fs.readFileSync(path.join(process.cwd(), 'src', 'CsvEditorProvider.ts'), 'utf8');
8+
const webviewSource = fs.readFileSync(path.join(process.cwd(), 'media', 'main.js'), 'utf8');
9+
10+
it('passes zoom settings from provider to webview root dataset', () => {
11+
assert.ok(providerSource.includes('data-wheelzoomenabled="${mouseWheelZoomEnabled ? \'1\' : \'0\'}"'));
12+
assert.ok(providerSource.includes('data-wheelzoominvert="${mouseWheelZoomInvert ? \'1\' : \'0\'}"'));
13+
});
14+
15+
it('handles Ctrl/Cmd zoom keyboard shortcuts (+, -, 0)', () => {
16+
assert.ok(webviewSource.includes('const maybeHandleZoomShortcut = e => {'));
17+
assert.ok(webviewSource.includes("const isZoomInShortcut = e => e.code === 'NumpadAdd' || e.key === '+' || e.key === '=';"));
18+
assert.ok(webviewSource.includes("const isZoomOutShortcut = e => e.code === 'NumpadSubtract' || e.key === '-' || e.key === '_';"));
19+
assert.ok(webviewSource.includes("const isZoomResetShortcut = e => e.key === '0';"));
20+
assert.ok(webviewSource.includes('if (maybeHandleZoomShortcut(e)) {'));
21+
});
22+
23+
it('handles Ctrl/Cmd mouse wheel zoom and supports invert direction', () => {
24+
assert.ok(webviewSource.includes('const MOUSE_WHEEL_ZOOM_ENABLED = root?.dataset?.wheelzoomenabled !== \'0\';'));
25+
assert.ok(webviewSource.includes('const MOUSE_WHEEL_ZOOM_INVERTED = root?.dataset?.wheelzoominvert === \'1\';'));
26+
assert.ok(webviewSource.includes('const handleZoomWheel = e => {'));
27+
assert.ok(webviewSource.includes('const direction = MOUSE_WHEEL_ZOOM_INVERTED ? -naturalDirection : naturalDirection;'));
28+
assert.ok(webviewSource.includes("window.addEventListener('wheel', handleZoomWheel, { passive: false });"));
29+
});
30+
});

0 commit comments

Comments
 (0)