Skip to content

Commit 8313a46

Browse files
committed
Fix Enter/Tab edit flow to commit and move selection without auto-edit
1 parent e4fd566 commit 8313a46

File tree

5 files changed

+259
-25
lines changed

5 files changed

+259
-25
lines changed

README.md

Lines changed: 2 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
- **Column Sorting:** Right-click a header and choose A–Z or Z–A.
3939
- **Custom Font Selection:** Choose a font from a dropdown or inherit VS Code's default.
4040
- **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).
41+
- **Multiline Cell Display:** Cells with embedded newlines render as wrapped multi-line content (with preserved line breaks and matching row height).
4142
- **Clickable Links:** URLs in cells are automatically detected and displayed as clickable links. Ctrl/Cmd+click to open them in your browser.
4243
- **Preserved CSV Integrity:** All modifications respect CSV formatting—no unwanted extra characters or formatting issues.
4344
- **Optimized for Performance:** Designed for medium-sized datasets, ensuring a smooth editing experience without compromising on functionality.
@@ -123,6 +124,7 @@ Per-file (stored by the extension; set via commands):
123124
- Detail edit:
124125
- Start: press `Enter` on a selected cell or double‑click a cell.
125126
- Caret navigation: Arrow Left/Right move one character; Arrow Up moves caret to start; Arrow Down moves caret to end.
127+
- New line in cell: `Shift + Enter` inserts a line break inside the current cell.
126128
- Exit/save: click outside the cell or move focus to commit changes.
127129
- Global:
128130
- Copy selection: `Ctrl/Cmd + C`

media/main.js

Lines changed: 165 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,12 +1436,50 @@ document.addEventListener('keydown', e => {
14361436
return;
14371437
}
14381438

1439+
if (!editingCell && e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
1440+
e.preventDefault();
1441+
const refCell = anchorCell || getCellTarget(document.activeElement) || currentSelection[0] || document.querySelector('td.selected, th.selected');
1442+
if (!refCell) return;
1443+
const coords = getCellCoords(refCell);
1444+
if (!coords || !Number.isInteger(coords.row) || !Number.isInteger(coords.col) || coords.col < 0) {
1445+
return;
1446+
}
1447+
const bounds = getDataColumnBounds();
1448+
if (!bounds) return;
1449+
const { minCol, maxCol } = bounds;
1450+
const firstDataRow = getFirstDataRow();
1451+
const isBackward = !!e.shiftKey;
1452+
let targetRow = coords.row;
1453+
let targetCol = coords.col + (isBackward ? -1 : 1);
1454+
if (!isBackward && targetCol > maxCol) {
1455+
targetRow += 1;
1456+
targetCol = minCol;
1457+
} else if (isBackward && targetCol < minCol) {
1458+
if (targetRow <= firstDataRow) {
1459+
return;
1460+
}
1461+
targetRow -= 1;
1462+
targetCol = maxCol;
1463+
}
1464+
const nextCell = ensureRenderedCellByCoords(targetRow, targetCol);
1465+
if (nextCell) {
1466+
setSingleSelection(nextCell);
1467+
}
1468+
return;
1469+
}
1470+
14391471
/* ──────── NEW: ENTER + DIRECT TYPING HANDLERS ──────── */
14401472
if (!editingCell && anchorCell && currentSelection.length === 1) {
14411473
if (e.key === 'Enter') {
14421474
e.preventDefault();
1475+
const cell = anchorCell;
14431476
// Detail edit via Enter
1444-
editCell(anchorCell, undefined, 'detail');
1477+
editCell(cell, undefined, 'detail');
1478+
if (e.shiftKey) {
1479+
// Shift+Enter from selection should open detail edit and insert
1480+
// a newline immediately on the very first keypress.
1481+
appendVisibleNewlineAtEnd(cell);
1482+
}
14451483
return;
14461484
}
14471485
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
@@ -1560,31 +1598,57 @@ document.addEventListener('keydown', e => {
15601598
}
15611599
if (editingCell && e.key === 'Enter') {
15621600
e.preventDefault();
1601+
if (e.shiftKey) {
1602+
if (!insertNewlineAtCaret(editingCell)) {
1603+
appendVisibleNewlineAtEnd(editingCell);
1604+
}
1605+
return;
1606+
}
15631607
const { row, col } = getCellCoords(editingCell);
15641608
editingCell.blur();
15651609
const targetRow = row + 1;
1566-
const nextCell = table.querySelector('td[data-row="'+targetRow+'\"][data-col="'+col+'"]');
1610+
// Editing Enter commits and moves selection down (no auto-edit).
1611+
const nextCell = ensureRenderedCellByCoords(targetRow, col);
15671612
if (nextCell) {
1568-
editCell(nextCell);
1613+
setSingleSelection(nextCell);
15691614
} else {
15701615
try {
15711616
const st = vscode.getState() || {};
1572-
vscode.setState({ ...st, anchorRow: targetRow, anchorCol: col, pendingEdit: 'detail' });
1617+
vscode.setState({ ...st, anchorRow: targetRow, anchorCol: col });
15731618
} catch {}
15741619
}
15751620
}
15761621
if (editingCell && e.key === 'Tab') {
15771622
e.preventDefault();
1578-
const { row, col } = getCellCoords(editingCell);
1579-
editingCell.blur();
1580-
let nextCell;
1581-
if (e.shiftKey) {
1582-
nextCell = table.querySelector('td[data-row="'+row+'"][data-col="'+(col-1)+'"]');
1583-
} else {
1584-
nextCell = table.querySelector('td[data-row="'+row+'"][data-col="'+(col+1)+'"]');
1623+
const cell = editingCell;
1624+
const { row, col } = getCellCoords(cell);
1625+
const bounds = getDataColumnBounds();
1626+
const firstDataRow = getFirstDataRow();
1627+
const isBackward = !!e.shiftKey;
1628+
let targetRow = row;
1629+
let targetCol = col;
1630+
let canMove = !!bounds;
1631+
if (bounds) {
1632+
targetCol = col + (isBackward ? -1 : 1);
1633+
if (!isBackward && targetCol > bounds.maxCol) {
1634+
targetRow += 1;
1635+
targetCol = bounds.minCol;
1636+
} else if (isBackward && targetCol < bounds.minCol) {
1637+
if (targetRow <= firstDataRow) {
1638+
canMove = false;
1639+
} else {
1640+
targetRow -= 1;
1641+
targetCol = bounds.maxCol;
1642+
}
1643+
}
15851644
}
1645+
cell.blur();
1646+
// Editing Tab commits and moves selection only (no auto-edit).
1647+
const nextCell = canMove ? ensureRenderedCellByCoords(targetRow, targetCol) : null;
15861648
if (nextCell) {
1587-
editCell(nextCell);
1649+
setSingleSelection(nextCell);
1650+
} else {
1651+
setSingleSelection(cell);
15881652
}
15891653
}
15901654
if (editingCell && e.key === 'Escape') {
@@ -1614,6 +1678,94 @@ const setCursorAtPoint = (cell, x, y) => {
16141678
if(range){ let sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }
16151679
};
16161680

1681+
const getDataColumnBounds = () => {
1682+
const cols = Array.from(table.querySelectorAll('td[data-col], th[data-col]'))
1683+
.map(el => parseInt(el.getAttribute('data-col') || 'NaN', 10))
1684+
.filter(col => Number.isInteger(col) && col >= 0);
1685+
if (!cols.length) {
1686+
return null;
1687+
}
1688+
return { minCol: Math.min(...cols), maxCol: Math.max(...cols) };
1689+
};
1690+
1691+
const setSingleSelection = cell => {
1692+
if (!cell) return;
1693+
clearSelection();
1694+
cell.classList.add('selected');
1695+
currentSelection.push(cell);
1696+
anchorCell = cell;
1697+
rangeEndCell = cell;
1698+
persistState();
1699+
try { cell.focus({ preventScroll: true }); } catch { try { cell.focus(); } catch {} }
1700+
cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
1701+
};
1702+
1703+
const NEWLINE_SENTINEL_ATTR = 'data-csv-newline-sentinel';
1704+
const removeNewlineSentinels = cell => {
1705+
if (!cell) return;
1706+
cell.querySelectorAll(`[${NEWLINE_SENTINEL_ATTR}="true"]`).forEach(node => node.remove());
1707+
};
1708+
1709+
const placeCaretBeforeSentinel = sentinel => {
1710+
const sel = window.getSelection();
1711+
if (!sel) return;
1712+
const range = document.createRange();
1713+
if (sentinel.firstChild) {
1714+
range.setStart(sentinel.firstChild, 0);
1715+
} else {
1716+
range.setStartBefore(sentinel);
1717+
}
1718+
range.collapse(true);
1719+
sel.removeAllRanges();
1720+
sel.addRange(range);
1721+
};
1722+
1723+
const appendVisibleNewlineAtEnd = cell => {
1724+
removeNewlineSentinels(cell);
1725+
const sentinel = document.createElement('span');
1726+
sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true');
1727+
sentinel.textContent = '\u200B';
1728+
cell.appendChild(document.createTextNode('\n'));
1729+
cell.appendChild(sentinel);
1730+
placeCaretBeforeSentinel(sentinel);
1731+
};
1732+
1733+
const isRangeAtEndOfCell = (cell, range) => {
1734+
const probe = document.createRange();
1735+
probe.selectNodeContents(cell);
1736+
probe.setEnd(range.endContainer, range.endOffset);
1737+
const caretOffset = probe.toString().length;
1738+
return caretOffset >= (cell.textContent || '').length;
1739+
};
1740+
1741+
const insertNewlineAtCaret = cell => {
1742+
removeNewlineSentinels(cell);
1743+
const sel = window.getSelection();
1744+
if (!sel || sel.rangeCount === 0) return false;
1745+
const range = sel.getRangeAt(0);
1746+
if (!cell.contains(range.commonAncestorContainer)) return false;
1747+
const atEnd = range.collapsed && isRangeAtEndOfCell(cell, range);
1748+
range.deleteContents();
1749+
if (atEnd) {
1750+
const sentinel = document.createElement('span');
1751+
sentinel.setAttribute(NEWLINE_SENTINEL_ATTR, 'true');
1752+
sentinel.textContent = '\u200B';
1753+
const fragment = document.createDocumentFragment();
1754+
fragment.appendChild(document.createTextNode('\n'));
1755+
fragment.appendChild(sentinel);
1756+
range.insertNode(fragment);
1757+
placeCaretBeforeSentinel(sentinel);
1758+
return true;
1759+
}
1760+
const newlineNode = document.createTextNode('\n');
1761+
range.insertNode(newlineNode);
1762+
range.setStartAfter(newlineNode);
1763+
range.setEndAfter(newlineNode);
1764+
sel.removeAllRanges();
1765+
sel.addRange(range);
1766+
return true;
1767+
};
1768+
16171769
const editCell = (cell, event, mode = 'detail') => {
16181770
if(editingCell === cell) return;
16191771
if(editingCell) editingCell.blur();
@@ -1625,6 +1777,7 @@ const editCell = (cell, event, mode = 'detail') => {
16251777
cell.setAttribute('contenteditable', 'true');
16261778
cell.focus();
16271779
const onBlurHandler = () => {
1780+
removeNewlineSentinels(cell);
16281781
const value = cell.textContent;
16291782
const coords = getCellCoords(cell);
16301783
vscode.postMessage({ type: 'editCell', row: coords.row, col: coords.col, value: value });

src/CsvEditorProvider.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,8 +1057,10 @@ class CsvEditorController {
10571057
const displayIdx = i + localR + 1; // numbering relative to first visible data row
10581058
let cells = '';
10591059
for (let cIdx = 0; cIdx < numColumns; cIdx++) {
1060-
const safe = this.formatCellContent(row[cIdx] || '', clickableLinks);
1061-
cells += `<td tabindex="0" style="min-width:${Math.min(columnWidths[cIdx]||0,100)}ch;max-width:100ch;border:1px solid ${isDark?'#555':'#ccc'};color:${columnColors[cIdx]};overflow:hidden;white-space: pre;text-overflow:ellipsis;" data-row="${absRow}" data-col="${cIdx}">${safe}</td>`;
1060+
const rawValue = row[cIdx] || '';
1061+
const safe = this.formatCellContent(rawValue, clickableLinks);
1062+
const titleAttr = this.getMultilineCellTitleAttr(rawValue);
1063+
cells += `<td tabindex="0" style="min-width:${Math.min(columnWidths[cIdx]||0,100)}ch;max-width:100ch;border:1px solid ${isDark?'#555':'#ccc'};color:${columnColors[cIdx]};overflow:visible;white-space: pre-wrap;overflow-wrap:anywhere;"${titleAttr} data-row="${absRow}" data-col="${cIdx}">${safe}</td>`;
10621064
}
10631065

10641066
return `<tr>${
@@ -1094,7 +1096,7 @@ class CsvEditorController {
10941096
}`;
10951097
for (let i = 0; i < numColumns; i++) {
10961098
const safe = this.formatCellContent(headerRow[i] || '', clickableLinks);
1097-
tableHtml += `<th tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${offset}" data-col="${i}">${safe}</th>`;
1099+
tableHtml += `<th tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; color: ${columnColors[i]}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;" data-row="${offset}" data-col="${i}">${safe}</th>`;
10981100
}
10991101
tableHtml += `</tr></thead><tbody>`;
11001102
bodyData.forEach((row, r) => {
@@ -1104,15 +1106,17 @@ class CsvEditorController {
11041106
: ''
11051107
}`;
11061108
for (let i = 0; i < numColumns; i++) {
1107-
const safe = this.formatCellContent(row[i] || '', clickableLinks);
1108-
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${offset + 1 + r}" data-col="${i}">${safe}</td>`;
1109+
const rawValue = row[i] || '';
1110+
const safe = this.formatCellContent(rawValue, clickableLinks);
1111+
const titleAttr = this.getMultilineCellTitleAttr(rawValue);
1112+
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere;"${titleAttr} data-row="${offset + 1 + r}" data-col="${i}">${safe}</td>`;
11091113
}
11101114
tableHtml += `</tr>`;
11111115
});
11121116
if (!chunked) {
11131117
const virtualAbs = offset + 1 + bodyData.length;
11141118
const idxCell = addSerialIndex ? `<td tabindex="0" style="min-width: ${serialIndexWidthCh}ch; max-width: ${serialIndexWidthCh}ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: #888;" data-row="${virtualAbs}" data-col="-1">${bodyData.length + 1}</td>` : '';
1115-
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
1119+
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
11161120
tableHtml += `<tr>${idxCell}${dataCells}</tr>`;
11171121
}
11181122
tableHtml += `</tbody>`;
@@ -1127,16 +1131,18 @@ class CsvEditorController {
11271131
: ''
11281132
}`;
11291133
for (let i = 0; i < numColumns; i++) {
1130-
const safe = this.formatCellContent(row[i] || '', clickableLinks);
1131-
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${offset + r}" data-col="${i}">${safe}</td>`;
1134+
const rawValue = row[i] || '';
1135+
const safe = this.formatCellContent(rawValue, clickableLinks);
1136+
const titleAttr = this.getMultilineCellTitleAttr(rawValue);
1137+
tableHtml += `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere;"${titleAttr} data-row="${offset + r}" data-col="${i}">${safe}</td>`;
11321138
}
11331139
tableHtml += `</tr>`;
11341140
});
11351141
if (!chunked) {
11361142
const virtualAbs = offset + nonHeaderRows.length;
11371143
const displayIdx = nonHeaderRows.length + 1;
11381144
const idxCell = addSerialIndex ? `<td tabindex="0" style="min-width: ${serialIndexWidthCh}ch; max-width: ${serialIndexWidthCh}ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: #888;" data-row="${virtualAbs}" data-col="-1">${displayIdx}</td>` : '';
1139-
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
1145+
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
11401146
tableHtml += `<tr>${idxCell}${dataCells}</tr>`;
11411147
}
11421148
tableHtml += `</tbody>`;
@@ -1148,7 +1154,7 @@ class CsvEditorController {
11481154
const virtualAbs = startAbs + allRowsCount;
11491155
const displayIdx = allRowsCount + 1;
11501156
const idxCell = addSerialIndex ? `<td tabindex="0" style="min-width: ${serialIndexWidthCh}ch; max-width: ${serialIndexWidthCh}ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: #888;" data-row="${virtualAbs}" data-col="-1">${displayIdx}</td>` : '';
1151-
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: hidden; white-space: pre; text-overflow: ellipsis;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
1157+
const dataCells = Array.from({ length: numColumns }, (_, i) => `<td tabindex="0" style="min-width: ${Math.min(columnWidths[i] || 0, 100)}ch; max-width: 100ch; border: 1px solid ${isDark ? '#555' : '#ccc'}; color: ${columnColors[i]}; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere;" data-row="${virtualAbs}" data-col="${i}"></td>`).join('');
11521158
const vrow = `<tr>${idxCell}${dataCells}</tr>`;
11531159
chunks.push(vrow);
11541160
}
@@ -1216,10 +1222,11 @@ class CsvEditorController {
12161222
body { font-family: ${this.escapeCss(fontFamily)}; margin: 0; padding: 0; user-select: none; }
12171223
.table-container { overflow: auto; height: 100vh; }
12181224
table { border-collapse: collapse; width: max-content; }
1219-
th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; overflow: hidden; white-space: pre; text-overflow: ellipsis; }
1220-
th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; }
1225+
th, td { padding: ${cellPadding}px 8px; border: 1px solid ${isDark ? '#555' : '#ccc'}; }
1226+
th { position: sticky; top: 0; background-color: ${isDark ? '#1e1e1e' : '#ffffff'}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
1227+
td { overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; }
12211228
td.selected, th.selected { background-color: ${isDark ? '#333333' : '#cce0ff'} !important; }
1222-
td.editing, th.editing { overflow: visible !important; white-space: normal !important; max-width: none !important; }
1229+
td.editing, th.editing { overflow: visible !important; white-space: pre-wrap !important; overflow-wrap: anywhere !important; max-width: none !important; }
12231230
.highlight { background-color: ${isDark ? '#2a2a2a' : '#fefefe'} !important; }
12241231
.active-match { background-color: ${isDark ? '#444444' : '#ffffcc'} !important; }
12251232
.csv-link { color: ${isDark ? '#6cb6ff' : '#0066cc'}; text-decoration: underline; cursor: pointer; }
@@ -1571,6 +1578,13 @@ class CsvEditorController {
15711578
return linkify ? this.linkifyUrls(escaped) : escaped;
15721579
}
15731580

1581+
private getMultilineCellTitleAttr(text: string): string {
1582+
if (!text || (text.indexOf('\n') === -1 && text.indexOf('\r') === -1)) {
1583+
return '';
1584+
}
1585+
return ` title="${this.escapeHtml(text)}"`;
1586+
}
1587+
15741588
private escapeCss(text: string): string {
15751589
// conservative; ok for font-family lists
15761590
return text.replace(/[\\"]/g, m => (m === '\\' ? '\\\\' : '\\"'));

0 commit comments

Comments
 (0)