feat: enhance table cell editing with inline previews and improved selection styles

This commit is contained in:
Vadim Melnicuk
2026-02-22 22:57:26 +00:00
parent 35c489c9c4
commit c4cb2a90a2
2 changed files with 239 additions and 12 deletions
+169 -8
View File
@@ -29,6 +29,103 @@ function isRedoShortcut(event) {
return (key === 'z' && event.shiftKey) || key === 'y';
}
function isPreviewWhitespaceOnly(text) {
return /^\s+$/.test(text);
}
function isPreviewEscapedChar(text, index) {
let slashCount = 0;
for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) {
slashCount += 1;
}
return (slashCount % 2) === 1;
}
function findClosingInlineMarker(text, startIndex, marker) {
const markerLen = marker.length;
for (let i = startIndex; i <= text.length - markerLen; i += 1) {
if (!text.startsWith(marker, i)) continue;
if (isPreviewEscapedChar(text, i)) continue;
return i;
}
return -1;
}
function parseInlinePreviewSpan(text, index) {
const strongMarker = text.startsWith('**', index)
? '**'
: (text.startsWith('__', index) ? '__' : null);
if (strongMarker) {
const contentStart = index + 2;
const closing = findClosingInlineMarker(text, contentStart, strongMarker);
if (closing > contentStart) {
const content = text.slice(contentStart, closing);
if (!isPreviewWhitespaceOnly(content)) {
return { tag: 'strong', content, nextIndex: closing + 2 };
}
}
}
const emMarker = text[index] === '*' || text[index] === '_' ? text[index] : null;
if (!emMarker) return null;
if (text[index + 1] === emMarker) return null;
const contentStart = index + 1;
const closing = findClosingInlineMarker(text, contentStart, emMarker);
if (closing <= contentStart) return null;
const content = text.slice(contentStart, closing);
if (isPreviewWhitespaceOnly(content)) return null;
return { tag: 'em', content, nextIndex: closing + 1 };
}
function appendInlinePreviewNodes(parent, text) {
let buffer = '';
const flushBuffer = () => {
if (!buffer) return;
parent.appendChild(document.createTextNode(buffer));
buffer = '';
};
for (let i = 0; i < text.length;) {
const ch = text[i];
if (ch === '\\' && i + 1 < text.length) {
const next = text[i + 1];
if (next === '\\' || next === '*' || next === '_') {
buffer += next;
i += 2;
continue;
}
}
if (ch !== '*' && ch !== '_') {
buffer += ch;
i += 1;
continue;
}
const span = parseInlinePreviewSpan(text, i);
if (!span) {
buffer += ch;
i += 1;
continue;
}
flushBuffer();
const el = document.createElement(span.tag);
appendInlinePreviewNodes(el, span.content);
parent.appendChild(el);
i = span.nextIndex;
}
flushBuffer();
}
function renderTableCellInlinePreview(previewEl, value) {
previewEl.replaceChildren();
appendInlinePreviewNodes(previewEl, value);
}
function normalizeRow(cells, colCount) {
const result = cells.slice(0, colCount);
while (result.length < colCount) result.push('');
@@ -275,6 +372,7 @@ class HtmlTableWidget extends WidgetType {
focusCellInput(cell, { updateSelection = false } = {}) {
const input = cell.querySelector('textarea');
if (!(input instanceof HTMLTextAreaElement)) return false;
this.setCellEditingState(input, true);
input.focus({ preventScroll: true });
const caret = input.value.length;
input.setSelectionRange(caret, caret);
@@ -303,12 +401,25 @@ class HtmlTableWidget extends WidgetType {
applySelection(range) {
if (!this.domRefs) return;
this.selectionRange = range;
const showSelectionStyle = Boolean(
range && (range.fromRow !== range.toRow || range.fromCol !== range.toCol)
);
const { cellGrid } = this.domRefs;
for (let row = 0; row < cellGrid.length; row++) {
const cells = cellGrid[row];
for (let col = 0; col < cells.length; col++) {
const cell = cells[col];
const selected = this.isCellSelected(row, col, range);
cells[col].classList.toggle('meo-md-html-table-cell-selected', selected);
const styledSelected = selected && showSelectionStyle;
const isTopEdge = styledSelected && row === range.fromRow;
const isRightEdge = styledSelected && col === range.toCol;
const isBottomEdge = styledSelected && row === range.toRow;
const isLeftEdge = styledSelected && col === range.fromCol;
cell.classList.toggle('meo-md-html-table-cell-selected', styledSelected);
cell.classList.toggle('meo-md-html-table-cell-selected-top', isTopEdge);
cell.classList.toggle('meo-md-html-table-cell-selected-right', isRightEdge);
cell.classList.toggle('meo-md-html-table-cell-selected-bottom', isBottomEdge);
cell.classList.toggle('meo-md-html-table-cell-selected-left', isLeftEdge);
}
}
}
@@ -400,6 +511,11 @@ class HtmlTableWidget extends WidgetType {
if (!cell) return;
const current = this.coordsFromCell(cell);
if (!current) return;
if (event.target instanceof HTMLTextAreaElement) {
this.selectionAnchor = current;
this.applySelection(this.normalizeSelectionRange(current, current));
return;
}
const anchor = current;
this.selectionAnchor = anchor;
this.applySelection(this.normalizeSelectionRange(anchor, current));
@@ -408,6 +524,7 @@ class HtmlTableWidget extends WidgetType {
table.setPointerCapture?.(event.pointerId);
if (!(event.target instanceof HTMLTextAreaElement)) {
event.preventDefault();
this.focusCellInput(cell);
}
};
@@ -453,6 +570,7 @@ class HtmlTableWidget extends WidgetType {
const input = this.domRefs.allRowInputs[row][col];
if (input.value !== '') {
input.value = '';
this.refreshCellPreviewFromInput(input);
this.hasPendingCellEdits = true;
}
}
@@ -485,6 +603,7 @@ class HtmlTableWidget extends WidgetType {
if (targetCell) {
const targetInput = targetCell.querySelector('textarea');
if (targetInput !== active) {
event.preventDefault();
this.focusCellInput(targetCell, { updateSelection: true });
}
return;
@@ -625,17 +744,25 @@ class HtmlTableWidget extends WidgetType {
this.commitMatrix(matrix, dom);
}
wireInput(input, rowEl, rowInputs, container, rowIndex, colIndex) {
wireInput(input, rowEl, rowInputs, container, rowIndex, colIndex, preview) {
const refreshPreview = () => {
this.renderCellPreview(preview, input.value);
};
input.addEventListener('input', () => {
this.hasPendingCellEdits = true;
refreshPreview();
this.resizeRow(rowEl, rowInputs);
this.scheduleLayout();
});
input.addEventListener('focus', () => {
this.setCellEditingState(input, true);
this.setTableInteractionActive(container, true);
this.setSingleCellSelection({ row: rowIndex, col: colIndex });
});
input.addEventListener('blur', (event) => {
refreshPreview();
this.setCellEditingState(input, false);
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && container.contains(nextTarget)) return;
this.commit(container);
@@ -729,16 +856,50 @@ class HtmlTableWidget extends WidgetType {
});
}
createCellInput(value, rowEl, rowInputs, container, rowIndex, colIndex) {
renderCellPreview(preview, value) {
if (!(preview instanceof HTMLElement)) return;
renderTableCellInlinePreview(preview, value ?? '');
}
refreshCellPreviewFromInput(input) {
if (!(input instanceof HTMLTextAreaElement)) return;
const preview = input.parentElement?.querySelector('.meo-md-html-table-cell-preview');
this.renderCellPreview(preview, input.value);
}
setCellEditingState(input, isEditing) {
const content = input?.parentElement;
if (!(content instanceof HTMLElement)) return;
content.classList.toggle('is-editing', isEditing);
}
createCellPreview(value) {
const preview = document.createElement('div');
preview.className = 'meo-md-html-table-cell-preview';
preview.setAttribute('aria-hidden', 'true');
this.renderCellPreview(preview, value);
return preview;
}
createCellInput(value, rowIndex, colIndex) {
const input = document.createElement('textarea');
input.rows = 1;
input.value = value;
input.dataset.tableRow = String(rowIndex);
input.dataset.tableCol = String(colIndex);
this.wireInput(input, rowEl, rowInputs, container, rowIndex, colIndex);
return input;
}
createCellEditor(value, rowEl, rowInputs, container, rowIndex, colIndex) {
const content = document.createElement('div');
content.className = 'meo-md-html-table-cell-content';
const preview = this.createCellPreview(value);
const input = this.createCellInput(value, rowIndex, colIndex);
this.wireInput(input, rowEl, rowInputs, container, rowIndex, colIndex, preview);
content.append(preview, input);
return { content, input };
}
toDOM() {
const wrap = document.createElement('div');
wrap.className = 'meo-md-html-table-wrap';
@@ -766,10 +927,10 @@ class HtmlTableWidget extends WidgetType {
const th = document.createElement('th');
th.dataset.tableRow = '0';
th.dataset.tableCol = String(col);
const input = this.createCellInput(this.tableData.headerCells[col] ?? '', headerRow, headerInputs, wrap, 0, col);
const { content, input } = this.createCellEditor(this.tableData.headerCells[col] ?? '', headerRow, headerInputs, wrap, 0, col);
headerInputs.push(input);
headerCells.push(th);
th.appendChild(input);
th.appendChild(content);
if (col === 0) {
const leftInsertControls = document.createElement('div');
@@ -834,10 +995,10 @@ class HtmlTableWidget extends WidgetType {
const td = document.createElement('td');
td.dataset.tableRow = String(tableRowIndex);
td.dataset.tableCol = String(col);
const input = this.createCellInput(this.tableData.rows[rowIdx][col] ?? '', tr, inputs, wrap, tableRowIndex, col);
const { content, input } = this.createCellEditor(this.tableData.rows[rowIdx][col] ?? '', tr, inputs, wrap, tableRowIndex, col);
inputs.push(input);
bodyCells.push(td);
td.appendChild(input);
td.appendChild(content);
if (col === 0) {
if (rowIdx === 0) {
+70 -4
View File
@@ -242,9 +242,9 @@ body {
.export-dropdown-wrapper {
position: absolute;
top: 100%;
left: 50%;
left: calc(50% + 1px);
transform: translateX(-50%);
padding: 5px 0 0;
padding: 5px 25px;
z-index: 100;
opacity: 0;
pointer-events: none;
@@ -265,7 +265,7 @@ body {
.export-dropdown-option {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: center;
width: 100%;
min-height: 24px;
padding: 0 8px;
@@ -1299,12 +1299,68 @@ body {
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected,
.cm-editor .meo-md-html-table td.meo-md-html-table-cell-selected {
background: color-mix(in srgb, var(--vscode-editor-selectionBackground) 35%, transparent);
background: color-mix(in srgb, var(--meo-color-base05) 14%, transparent);
--meo-table-sel-top: transparent;
--meo-table-sel-right: transparent;
--meo-table-sel-bottom: transparent;
--meo-table-sel-left: transparent;
box-shadow:
inset 0 1px 0 var(--meo-table-sel-top),
inset -1px 0 0 var(--meo-table-sel-right),
inset 0 -1px 0 var(--meo-table-sel-bottom),
inset 1px 0 0 var(--meo-table-sel-left);
}
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected-top,
.cm-editor .meo-md-html-table td.meo-md-html-table-cell-selected-top {
--meo-table-sel-top: var(--meo-color-base05);
}
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected-right,
.cm-editor .meo-md-html-table td.meo-md-html-table-cell-selected-right {
--meo-table-sel-right: var(--meo-color-base05);
}
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected-bottom,
.cm-editor .meo-md-html-table td.meo-md-html-table-cell-selected-bottom {
--meo-table-sel-bottom: var(--meo-color-base05);
}
.cm-editor .meo-md-html-table th.meo-md-html-table-cell-selected-left,
.cm-editor .meo-md-html-table td.meo-md-html-table-cell-selected-left {
--meo-table-sel-left: var(--meo-color-base05);
}
.cm-editor .meo-md-html-table th {
position: relative;
background-color: var(--vscode-sideBar-background);
font-weight: inherit;
text-align: inherit;
}
.cm-editor .meo-md-html-table-cell-content {
display: grid;
}
.cm-editor .meo-md-html-table-cell-content > * {
grid-area: 1 / 1;
}
.cm-editor .meo-md-html-table-cell-preview {
display: block;
box-sizing: border-box;
min-height: 1lh;
padding: 0.25em 0.5em;
color: var(--vscode-editor-foreground);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
cursor: text;
}
.cm-editor .meo-md-html-table-cell-content.is-editing .meo-md-html-table-cell-preview {
opacity: 0;
pointer-events: none;
}
.cm-editor .meo-md-html-table textarea {
@@ -1319,9 +1375,19 @@ body {
background: transparent;
color: var(--vscode-editor-foreground);
font: inherit;
font-weight: inherit;
font-style: inherit;
line-height: inherit;
padding: 0.25em 0.5em;
resize: none;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.cm-editor .meo-md-html-table-cell-content.is-editing textarea {
opacity: 1;
pointer-events: auto;
}
.cm-editor .meo-md-html-table textarea:focus {