diff --git a/webview/src/helpers/tables.js b/webview/src/helpers/tables.js index f4ba29d..f46b61d 100644 --- a/webview/src/helpers/tables.js +++ b/webview/src/helpers/tables.js @@ -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) { diff --git a/webview/src/styles.css b/webview/src/styles.css index 1f25e7c..dfc52df 100644 --- a/webview/src/styles.css +++ b/webview/src/styles.css @@ -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 {