// Joplin-native interactive table editor widget for CodeMirror 6. // Clean design: no grip columns, table stays flush-left. // - Hover near row/column edges → "+" button appears via absolute positioning // - Right-click any cell → context menu for insert/move/delete // - Enter in last cell → adds new row // - Tab/Shift+Tab → navigate cells import { EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import { SyntaxNodeRef } from '@lezer/common'; import { getSearchQuery, searchPanelOpen, SearchQuery } from '@codemirror/search'; import sanitizeHtml from '../../../ProseMirror/utils/sanitizeHtml'; import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension'; import { focus, blur } from '@joplin/lib/utils/focusHandler'; import { parseTable, serializeTable, addRow, addColumn, deleteRow, deleteColumn, swapRows, swapColumns, Table, } from '../../utils/markdown/tableUtils'; import { getCellContentPosition } from '../../editorCommands/tableCommands'; // Short class name prefix const W = 'cm-tw'; const CELL = 'cm-tw-c'; const HDR = 'cm-tw-h'; const CTX = 'cm-tw-ctx'; // Cache for rendered table widget heights so CodeMirror can estimate // heights correctly for scroll position and coordinate mapping. const tableHeightCache = new Map(); // Remembers the last-focused cell coordinates per table widget, keyed by // the widget's document start position. Used to restore focus when the // widget is rebuilt without an explicit refocus path — for example after // an undo (Cmd+Z) reverts a cell edit. Cleared opportunistically when a // widget at the same `from` mounts but the coordinates fall outside the // new table's bounds. const lastFocusedCellByFrom = new Map(); // HTML-escape a string so captured cell text can be safely embedded into the // HTML fragment we hand to DOMPurify. DOMPurify is the actual safety net for // tag/attribute/URL filtering; this just keeps angle brackets and quotes in // the input from being interpreted as markup at all. const escapeHtml = (s: string): string => { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // Render a small subset of inline markdown into DOM children appended to // `parent`. Supports: **bold**, *italic* / _italic_, `code`, ~~strike~~, // [label](url), and literal
as a line break. Escaped pipes (\|) are // shown as plain |. The assembled HTML is run through DOMPurify before // insertion, so unsafe URL schemes (javascript:, data:, ...) and any tags // or attributes that slipped through the regex are removed. export const renderInlineMarkdown = (parent: HTMLElement, text: string) => { // Normalise: escaped pipes → |, and split on literal
for soft breaks. const normalised = text.replace(/\\\|/g, '|'); const segments = normalised.split(//i); const parts: string[] = []; for (let s = 0; s < segments.length; s++) { if (s > 0) parts.push('
'); const segment = segments[s]; // Single regex with alternatives, scanned left-to-right. Each branch // captures its inner content. Single * and _ emphasis use word- // boundary guards so identifiers like `foo_bar_baz` or `a*b*c` are // not rendered as emphasis. const re = /\*\*([^*]+)\*\*|__([^_]+)__|(? lastIdx) { parts.push(escapeHtml(segment.slice(lastIdx, m.index))); } if (m[1] !== undefined || m[2] !== undefined) { parts.push(`${escapeHtml((m[1] ?? m[2])!)}`); } else if (m[3] !== undefined || m[4] !== undefined) { parts.push(`${escapeHtml((m[3] ?? m[4])!)}`); } else if (m[5] !== undefined) { parts.push(`${escapeHtml(m[5])}`); } else if (m[6] !== undefined) { parts.push(`${escapeHtml(m[6])}`); } else { parts.push(`${escapeHtml(m[7]!)}`); } lastIdx = m.index + m[0].length; } if (lastIdx < segment.length) { parts.push(escapeHtml(segment.slice(lastIdx))); } } parent.innerHTML = sanitizeHtml(parts.join('')); }; class TableWidget extends WidgetType { public constructor( private tableText: string, private from: number, private to: number, ) { super(); this.cacheKey_ = `table_${from}_${to}_${tableText.length}`; } private cacheKey_: string; public eq(other: TableWidget) { return this.tableText === other.tableText && this.from === other.from && this.to === other.to; } public get estimatedHeight() { return tableHeightCache.get(this.cacheKey_) ?? -1; } // Find this widget's container after a rebuild by matching the document position. private findContainer(view: EditorView): HTMLElement | null { const containers = view.dom.querySelectorAll(`.${W}`); for (const c of containers) { try { const pos = view.posAtDOM(c); if (Math.abs(pos - this.from) < 3) return c as HTMLElement; } catch (_) { /* ignore */ } } // Fallback: return first container if only one table exists return containers.length === 1 ? containers[0] as HTMLElement : null; } // Save the horizontal scroll position of this widget's container before // a dispatch that will rebuild the widget, then restore it after rebuild. private saveAndRestoreScroll(view: EditorView) { const container = this.findContainer(view); const scrollLeft = container ? container.scrollLeft : 0; if (scrollLeft > 0) { requestAnimationFrame(() => { const newContainer = this.findContainer(view); if (newContainer) newContainer.scrollLeft = scrollLeft; }); } } // Dispatch a structural table change (add/delete row/column). // A trailing newline is appended when needed to ensure a blank line // separates the table from subsequent text, preventing the parser // from absorbing later lines as extra table rows. // `userEvent` lets the caller tag the dispatch so CM's history can // group adjacent typing-style transactions into one undo step (the // live-sync flush uses 'input.type' for this reason). private apply(view: EditorView, newTable: Table | null, userEvent?: string) { if (!newTable) return; this.saveAndRestoreScroll(view); const newText = serializeTable(newTable); const doc = view.state.doc; const afterTable = this.to < doc.length ? doc.sliceString(this.to, Math.min(this.to + 2, doc.length)) : ''; const needsBlankLine = !afterTable.startsWith('\n\n'); const insert = needsBlankLine ? `${newText}\n` : newText; view.dispatch({ changes: { from: this.from, to: this.to, insert }, userEvent, }); } public toDOM(view: EditorView) { // Use the owning document/window instead of globals so that // the widget works correctly in separate Electron windows. const doc = view.dom.ownerDocument; const win = doc.defaultView!; const table = parseTable(this.tableText); if (!table) { const pre = doc.createElement('pre'); pre.textContent = this.tableText; return pre; } const numCols = table.header.cells.length; const numBodyRows = table.body.length; const totalRows = numBodyRows + 1; const allCells: HTMLElement[][] = []; const container = doc.createElement('div'); container.classList.add(W); container.dataset.from = String(this.from); container.dataset.to = String(this.to); const tableEl = doc.createElement('table'); // Flag to skip onblur sync when Tab/Enter handles it let skipBlurSync = false; // Track scrollbar interaction to prevent widget rebuild during drag let scrollbarDragging = false; let lastFocusedTextDiv: HTMLElement | null = null; // Debounced dispatch so the document source stays in sync with cell // edits — important so the preview pane reflects in-cell changes // (e.g. deleting an image) without waiting for blur or a structural // edit. Cleared whenever a structural apply() happens. let liveSyncTimer: number | null = null; const cancelLiveSync = () => { if (liveSyncTimer !== null) { win.clearTimeout(liveSyncTimer); liveSyncTimer = null; } }; // Sync the focused cell's current text into the table model. Other // cells are kept in sync continuously via their oninput handler, so // this is just a final read of whichever cell is being edited right // now. Reading textContent of an unfocused cell would be wrong — // rendered cells have stripped markdown markers (** etc). const syncDirtyCells = () => { const active = doc.activeElement as HTMLElement | null; if (!active || !active.classList.contains('cm-tw-text')) return; if (!container.contains(active)) return; for (let ri = 0; ri < allCells.length; ri++) { for (let ci = 0; ci < allCells[ri].length; ci++) { const td = allCells[ri][ci].querySelector('.cm-tw-text') as HTMLElement; if (td !== active) continue; const v = (td.textContent || '').trim().replace(/\n/g, '
').replace(/\|/g, '\\|'); const isH = ri === 0; if (isH) table.header.cells[ci].content = v; else if (ri - 1 < table.body.length) table.body[ri - 1].cells[ci].content = v; } } }; // ---- Editable cell ---- const mkCell = (text: string, r: number, c: number, isHdr: boolean) => { const el = doc.createElement(isHdr ? 'th' : 'td'); el.classList.add(CELL); if (isHdr) el.classList.add(HDR); // Editable text lives in its own div — cell itself is NOT editable const textDiv = doc.createElement('div'); textDiv.classList.add('cm-tw-text'); textDiv.contentEditable = 'true'; textDiv.spellcheck = false; // When not focused, show rendered inline markdown. On focus we // swap to the raw source so the user edits the markdown text. renderInlineMarkdown(textDiv, text); // Push this cell's current edit-mode text into the table model. // Called on every input so the model stays in sync even if a // rebuild is triggered by an external event (image paste, toolbar // command, etc.) before the deferred blur handler runs. const pushToModel = () => { const v = (textDiv.textContent || '').trim() .replace(/\n/g, '
').replace(/\|/g, '\\|'); if (isHdr) table.header.cells[c].content = v; else if (r - 1 < table.body.length) table.body[r - 1].cells[c].content = v; }; // Caret position within the focused cell, as a plain offset into // textContent — robust across the rebuild that follows a dispatch. const caretOffset = (): number => { const sel = win.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0); if (!textDiv.contains(range.endContainer)) return 0; const pre = range.cloneRange(); pre.selectNodeContents(textDiv); pre.setEnd(range.endContainer, range.endOffset); return pre.toString().length; }; const scheduleLiveSync = () => { pushToModel(); cancelLiveSync(); const offset = caretOffset(); liveSyncTimer = win.setTimeout(() => { liveSyncTimer = null; if (!container.isConnected) return; const newText = serializeTable(table); if (newText === this.tableText) return; this.apply(view, table, 'input.type'); // Rebuild discards this DOM — locate the same cell in the // new widget and restore focus + caret. requestAnimationFrame(() => { const newC = this.findContainer(view); const cells = newC?.querySelectorAll('.cm-tw-text'); const idx = r * numCols + c; const target = cells && idx < cells.length ? cells[idx] as HTMLElement : null; if (!target) return; focus('TableWidget', target); // Caret restoration: put it `offset` characters into // the cell's text content. const sel = win.getSelection(); if (!sel) return; const range = doc.createRange(); let remaining = offset; const walker = doc.createTreeWalker(target, 4 /* SHOW_TEXT */); let placed = false; let node = walker.nextNode(); while (node) { const len = node.nodeValue?.length ?? 0; if (remaining <= len) { range.setStart(node, remaining); range.collapse(true); placed = true; break; } remaining -= len; node = walker.nextNode(); } if (!placed) { range.selectNodeContents(target); range.collapse(false); } sel.removeAllRanges(); sel.addRange(range); }); }, 500); }; // Track IME composition so we don't rebuild the cell DOM // mid-composition — rebuilding would cancel the IME and drop // any in-progress candidates. let isComposing = false; textDiv.addEventListener('compositionstart', () => { isComposing = true; cancelLiveSync(); }); textDiv.addEventListener('compositionend', () => { isComposing = false; scheduleLiveSync(); }); textDiv.oninput = () => { if (isComposing) return; scheduleLiveSync(); }; // Some browsers do not fire `input` reliably when non-text nodes // (e.g. ) are removed via Backspace inside contentEditable. // A MutationObserver catches DOM-level changes that `input` misses. const mo = new win.MutationObserver(() => { if (isComposing) return; if (doc.activeElement === textDiv) scheduleLiveSync(); }); mo.observe(textDiv, { childList: true, characterData: true, subtree: true }); // Sync CM cursor to this cell so toolbar commands work, and // swap the rendered DOM for the raw markdown source for editing. textDiv.onfocus = () => { lastFocusedTextDiv = textDiv; lastFocusedCellByFrom.set(this.from, { r, c }); // Replace formatted DOM with raw text. Use the current model // entry rather than the stale `text` closure so that edits // to other cells in this table are reflected. const src = isHdr ? table.header.cells[c]?.content ?? '' : table.body[r - 1]?.cells[c]?.content ?? ''; textDiv.textContent = src.replace(/\\\|/g, '|'); // Place caret at end so typing appends (matches prior behaviour // where cells started empty of selection). const sel = doc.defaultView!.getSelection(); if (sel) { const range = doc.createRange(); range.selectNodeContents(textDiv); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } const tableRange = { from: this.from, to: this.to, text: this.tableText, }; const cellPos = getCellContentPosition(view.state, tableRange, r, c); if (cellPos !== null) { view.dispatch({ selection: { anchor: cellPos, head: cellPos }, }); } }; textDiv.onblur = () => { if (skipBlurSync) { skipBlurSync = false; return; } cancelLiveSync(); // Defer sync so that a click on another cell in the same // table can register before the widget rebuilds. setTimeout(() => { // If the widget was rebuilt (e.g. by a "+" button or // context menu action), the old container is detached. // Do nothing — the rebuild already has the latest data. if (!container.isConnected) return; const v = (textDiv.textContent || '').trim(); const orig = isHdr ? table.header.cells[c]?.content : table.body[r - 1]?.cells[c]?.content; const changed = v !== orig; if (changed) { // If focus moved to another cell in this table, just // update the in-memory model — no dispatch/rebuild. // The markdown will sync on next structural edit or // when focus leaves the table entirely. if (scrollbarDragging || container.contains(doc.activeElement)) { const sanitised = v.replace(/\n/g, '
').replace(/\|/g, '\\|'); if (isHdr) table.header.cells[c].content = sanitised; else if (r - 1 < table.body.length) table.body[r - 1].cells[c].content = sanitised; } else { // Focus left the table — sync to markdown and rebuild. // The rebuild discards this DOM, so no swap-back needed. this.apply(view, table); return; } } // Swap raw text back to rendered markdown view. const src = isHdr ? table.header.cells[c]?.content ?? '' : table.body[r - 1]?.cells[c]?.content ?? ''; textDiv.textContent = ''; renderInlineMarkdown(textDiv, src); }, 80); }; textDiv.onkeydown = (e) => { // Block newlines — not allowed in markdown table cells if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); return; } if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); skipBlurSync = true; cancelLiveSync(); // Sync all dirty cells into the table model first syncDirtyCells(); // Check if any cell content actually changed const newText = serializeTable(table); const isDirty = newText !== this.tableText; // Compute target cell index const flat = allCells.flat(); const i = flat.indexOf(el); const nextIdx = e.shiftKey ? i - 1 : i + 1; if (nextIdx >= 0 && nextIdx < flat.length) { if (isDirty) { // Content changed — apply and refocus after rebuild this.apply(view, table); requestAnimationFrame(() => { const newC = this.findContainer(view); const cells = newC?.querySelectorAll('.cm-tw-text'); if (cells && nextIdx < cells.length) { focus('TableWidget', cells[nextIdx] as HTMLElement); } }); } else { // No changes — just move focus, no rebuild needed const targetText = flat[nextIdx].querySelector('.cm-tw-text') as HTMLElement; if (targetText) focus('TableWidget', targetText); } } else if (!e.shiftKey) { // Past last cell — add new row and focus its first cell const newTable = addRow(table, numBodyRows - 1); this.apply(view, newTable); const newRowIdx = totalRows * numCols; requestAnimationFrame(() => { const newC = this.findContainer(view); const cells = newC?.querySelectorAll('.cm-tw-text'); if (cells && newRowIdx < cells.length) { focus('TableWidget', cells[newRowIdx] as HTMLElement); } }); } } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); skipBlurSync = true; cancelLiveSync(); syncDirtyCells(); if (r === totalRows - 1 && c === numCols - 1) { this.apply(view, addRow(table, numBodyRows - 1)); } else { // Only apply if content actually changed const enterText = serializeTable(table); if (enterText !== this.tableText) { this.apply(view, table); } } } else if (e.key === 'Escape') { e.preventDefault(); blur('TableWidget', textDiv); } }; el.appendChild(textDiv); // Clicking anywhere in the cell (including empty space in tall rows) // should activate the text editor el.onmousedown = (e) => { if (e.target === el) { e.preventDefault(); focus('TableWidget', textDiv); } }; el.oncontextmenu = (e) => showCtx(e, r, c); return el; }; // ---- Hover "+" buttons (absolute positioned) ---- // These are tiny buttons that sit on the right/bottom edge of each cell // and appear only on hover. No extra columns needed. const mkAddColBtn = (afterCol: number, anchorCell: HTMLElement) => { const wrapper = doc.createElement('span'); wrapper.contentEditable = 'false'; wrapper.classList.add('cm-tw-ac-wrap'); const btn = doc.createElement('button'); btn.classList.add('cm-tw-ac'); btn.textContent = '+'; btn.title = 'Add column to the right'; btn.tabIndex = -1; btn.onmousedown = (e) => { if (e.button !== 0) return; // left-click only e.preventDefault(); e.stopPropagation(); syncDirtyCells(); this.apply(view, addColumn(table, afterCol)); // Focus the new column's header cell after rebuild requestAnimationFrame(() => { const newC = this.findContainer(view); const cells = newC?.querySelectorAll('.cm-tw-text'); const targetIdx = afterCol + 1; if (cells && targetIdx < cells.length) { focus('TableWidget', cells[targetIdx] as HTMLElement); } }); }; wrapper.appendChild(btn); anchorCell.appendChild(wrapper); }; const mkAddRowBtn = (afterBodyIdx: number, anchorCell: HTMLElement) => { const wrapper = doc.createElement('span'); wrapper.contentEditable = 'false'; wrapper.classList.add('cm-tw-ar-wrap'); const btn = doc.createElement('button'); btn.classList.add('cm-tw-ar'); btn.textContent = '+'; btn.title = 'Add row below'; btn.tabIndex = -1; btn.onmousedown = (e) => { if (e.button !== 0) return; // left-click only e.preventDefault(); e.stopPropagation(); syncDirtyCells(); this.apply(view, addRow(table, afterBodyIdx)); // Focus the first cell of the new row after rebuild const newNumCols = numCols; const targetIdx = (afterBodyIdx + 2) * newNumCols; requestAnimationFrame(() => { const newC = this.findContainer(view); const cells = newC?.querySelectorAll('.cm-tw-text'); if (cells && targetIdx < cells.length) { focus('TableWidget', cells[targetIdx] as HTMLElement); } }); }; wrapper.appendChild(btn); anchorCell.appendChild(wrapper); }; // ---- Build header ---- const thead = doc.createElement('thead'); const headerTr = doc.createElement('tr'); allCells[0] = []; for (let c = 0; c < numCols; c++) { const cell = mkCell(table.header.cells[c].content, 0, c, true); // "+" on right edge of every header cell → add column mkAddColBtn(c, cell); // "+" on bottom edge of first header cell → add row below header if (c === 0) mkAddRowBtn(-1, cell); allCells[0].push(cell); headerTr.appendChild(cell); } thead.appendChild(headerTr); tableEl.appendChild(thead); // ---- Build body ---- const tbody = doc.createElement('tbody'); for (let r = 0; r < numBodyRows; r++) { const tr = doc.createElement('tr'); allCells[r + 1] = []; for (let c = 0; c < numCols; c++) { const content = c < table.body[r].cells.length ? table.body[r].cells[c].content : ''; const cell = mkCell(content, r + 1, c, false); // "+" on bottom edge of first column cell → add row if (c === 0) mkAddRowBtn(r, cell); allCells[r + 1].push(cell); tr.appendChild(cell); } tbody.appendChild(tr); } tableEl.appendChild(tbody); container.appendChild(tableEl); // If this widget mounts at a position where a cell was focused just // before a rebuild (e.g. undo triggered the rebuild from outside), // restore focus to that cell. The map is cleared when focus leaves // the container (see focusout below), so a stale entry can only // exist while the user is actively editing this table — there is no // risk of stealing focus from unrelated work. const remembered = lastFocusedCellByFrom.get(this.from); if (remembered) { const { r: rr, c: cc } = remembered; const targetRow = allCells[rr]; const targetCell = targetRow ? targetRow[cc] : undefined; const targetText = targetCell?.querySelector('.cm-tw-text') as HTMLElement | null; if (targetText) { requestAnimationFrame(() => { // Re-check the map and DOM before stealing focus — the // user may have clicked elsewhere between mount and the // next frame, in which case the entry is gone. if (!lastFocusedCellByFrom.has(this.from)) return; if (!targetText.isConnected) return; focus('TableWidget', targetText); }); } else { // Coordinates no longer fit (row/column was removed) — drop the // stale entry. lastFocusedCellByFrom.delete(this.from); } } // Clear the remembered cell when focus leaves the table container, // so a later rebuild (e.g. from an unrelated document edit) does // not steal focus back. The deletion is deferred so a widget // rebuild — which detaches the old cell DOM and fires focusout for // that reason alone — does not lose the entry before the new // widget can consume it (this is what makes Cmd+Z restore focus). container.addEventListener('focusout', (e: FocusEvent) => { const next = e.relatedTarget as Node | null; if (next && container.contains(next)) return; const fromAtBlur = this.from; // Defer two frames so any rebuild-driven refocus (which is // itself scheduled via requestAnimationFrame from the new // widget's toDOM) has a chance to land before we decide that // focus genuinely left the table. requestAnimationFrame(() => requestAnimationFrame(() => { const newActive = doc.activeElement as HTMLElement | null; if (newActive?.classList.contains('cm-tw-text')) return; lastFocusedCellByFrom.delete(fromAtBlur); })); }); // ---- Highlight helpers ---- const highlightRow = (rowIdx: number) => { if (rowIdx >= 0 && rowIdx < allCells.length) { for (const cell of allCells[rowIdx]) { cell.classList.add('cm-tw-hl'); } } }; const highlightCol = (colIdx: number) => { for (const row of allCells) { if (colIdx >= 0 && colIdx < row.length) { row[colIdx].classList.add('cm-tw-hl'); } } }; const clearHighlight = () => { for (const el of tableEl.querySelectorAll('.cm-tw-hl')) { el.classList.remove('cm-tw-hl'); } }; // ---- Context menu ---- const showCtx = (e: MouseEvent, r: number, c: number) => { e.preventDefault(); container.querySelector(`.${CTX}`)?.remove(); clearHighlight(); const menu = doc.createElement('div'); menu.classList.add(CTX); // Use viewport coordinates since the menu is position:fixed menu.style.left = `${e.clientX}px`; menu.style.top = `${e.clientY}px`; type MenuItem = { label: string; action: ()=> void; hlRow?: number; hlCol?: number }; const items: MenuItem[] = [ { label: '+ Insert row above', action: () => { syncDirtyCells(); this.apply(view, addRow(table, r <= 0 ? -1 : r - 2)); } }, { label: '+ Insert row below', action: () => { syncDirtyCells(); this.apply(view, addRow(table, r === 0 ? -1 : r - 1)); } }, { label: '+ Insert column left', action: () => { syncDirtyCells(); this.apply(view, addColumn(table, c - 1)); } }, { label: '+ Insert column right', action: () => { syncDirtyCells(); this.apply(view, addColumn(table, c)); } }, ]; if (r > 1) items.push({ label: '↑ Move row up', action: () => { syncDirtyCells(); this.apply(view, swapRows(table, r - 1, r - 2)); }, hlRow: r }); if (r > 0 && r < numBodyRows) items.push({ label: '↓ Move row down', action: () => { syncDirtyCells(); this.apply(view, swapRows(table, r - 1, r)); }, hlRow: r }); if (c > 0) items.push({ label: '← Move column left', action: () => { syncDirtyCells(); this.apply(view, swapColumns(table, c, c - 1)); }, hlCol: c }); if (c < numCols - 1) items.push({ label: '→ Move column right', action: () => { syncDirtyCells(); this.apply(view, swapColumns(table, c, c + 1)); }, hlCol: c }); // Delete row: only for body rows (header row cannot be removed) if (r > 0) { items.push({ label: '✕ Delete row', action: () => { syncDirtyCells(); this.apply(view, deleteRow(table, r - 1)); }, hlRow: r, }); } // Delete column: last column → delete entire table, otherwise delete that column items.push({ label: '✕ Delete column', action: () => { syncDirtyCells(); if (numCols <= 1) { view.dispatch({ changes: { from: this.from, to: this.to, insert: '' } }); } else { this.apply(view, deleteColumn(table, c)); } }, hlCol: c, }); for (const item of items) { const div = doc.createElement('div'); div.textContent = item.label; div.onmouseenter = () => { clearHighlight(); if (item.hlRow !== undefined) highlightRow(item.hlRow); if (item.hlCol !== undefined) highlightCol(item.hlCol); }; div.onmouseleave = () => clearHighlight(); div.onmousedown = (ev) => { ev.preventDefault(); ev.stopPropagation(); clearHighlight(); menu.remove(); item.action(); }; menu.appendChild(div); } container.appendChild(menu); // Clamp menu position so it stays within the viewport const menuRect = menu.getBoundingClientRect(); const vw = win.innerWidth; const vh = win.innerHeight; if (menuRect.right > vw) menu.style.left = `${vw - menuRect.width - 4}px`; if (menuRect.bottom > vh) menu.style.top = `${vh - menuRect.height - 4}px`; const close = () => { clearHighlight(); menu.remove(); doc.removeEventListener('mousedown', close); win.removeEventListener('scroll', close, true); }; setTimeout(() => { doc.addEventListener('mousedown', close); // Close menu on any scroll (capture phase catches scrollable parents) win.addEventListener('scroll', close, true); }, 0); }; // Measure and cache the rendered height so CodeMirror can correctly // calculate scroll positions and coordinate mapping. requestAnimationFrame(() => { if (container.isConnected) { tableHeightCache.set(this.cacheKey_, container.offsetHeight); } }); // Detect scrollbar/container clicks — prevent cell blur so the // widget is not rebuilt mid-scroll and the cell editor stays open. container.addEventListener('mousedown', (e) => { if (e.target === container) { e.preventDefault(); scrollbarDragging = true; const onUp = () => { scrollbarDragging = false; doc.removeEventListener('mouseup', onUp); // If blur fired despite preventDefault, re-focus the cell if (lastFocusedTextDiv && container.isConnected && doc.activeElement !== lastFocusedTextDiv) { focus('TableWidget', lastFocusedTextDiv); } }; doc.addEventListener('mouseup', onUp); } }); return container; } public ignoreEvent() { return true; } } // ===================== THEME ===================== const tableTheme = EditorView.theme({ // Root — no border, no background, just positioning context [`& .${W}`]: { position: 'relative', display: 'block', width: '100%', maxWidth: '100%', overflowX: 'auto', contain: 'inline-size', outline: 'none', padding: '18px 14px', boxSizing: 'border-box', }, [`& .${W} table`]: { borderCollapse: 'collapse', tableLayout: 'auto', }, // Selection halo when the document selection covers the whole table. [`& .${W}.cm-tw-selected`]: { backgroundColor: 'var(--joplin-selected-color, rgba(0,120,255,0.18))', borderRadius: '4px', }, // Search match highlight on a cell whose text matches the active query. ['& .cm-tw-text.cm-tw-match']: { backgroundColor: 'var(--joplin-search-marker-background-color, rgba(255, 220, 0, 0.45))', color: 'var(--joplin-search-marker-color, inherit)', }, // Cells [`& .${CELL}`]: { border: '1px solid var(--joplin-divider-color, #ddd)', padding: '6px 10px', minWidth: '50px', outline: 'none', verticalAlign: 'top', lineHeight: '1.5', fontSize: 'inherit', fontFamily: 'inherit', position: 'relative', // anchor for + buttons }, // Editable text area inside cells ['& .cm-tw-text']: { outline: 'none', minHeight: '1.2em', height: '100%', width: '100%', display: 'block', cursor: 'text', margin: '0', padding: '0', boxSizing: 'border-box', whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, ['& .cm-tw-text:focus']: { outline: 'none', }, // Highlight the entire cell when its text div is focused [`& .${CELL}:focus-within`]: { backgroundColor: 'var(--joplin-selected-color, rgba(0,120,255,0.06))', boxShadow: 'inset 0 0 0 2px var(--joplin-color3, #0078ff)', }, [`& .${HDR}`]: { fontWeight: 'bold', backgroundColor: 'var(--joplin-background-color3, #f0f0f0)', }, // Highlight for row/column on context menu hover ['& .cm-tw-hl']: { backgroundColor: 'var(--joplin-selected-color, rgba(0,120,255,0.12)) !important', }, // Wrapper for "+" buttons — non-editable island inside contentEditable cells ['& .cm-tw-ac-wrap']: { position: 'absolute', top: '50%', right: '-12px', transform: 'translateY(-50%)', zIndex: '30', display: 'none', }, [`& .${CELL}:hover > .cm-tw-ac-wrap`]: { display: 'block', }, ['& .cm-tw-ar-wrap']: { position: 'absolute', bottom: '-12px', left: '50%', transform: 'translateX(-50%)', zIndex: '30', display: 'none', }, [`& .${CELL}:hover > .cm-tw-ar-wrap`]: { display: 'block', }, // "+" button styles (shared) ['& .cm-tw-ac, & .cm-tw-ar']: { width: '22px', height: '22px', lineHeight: '20px', fontSize: '16px', fontWeight: 'bold', border: '1px solid var(--joplin-divider-color, #ccc)', borderRadius: '50%', backgroundColor: 'var(--joplin-background-color, #fff)', color: 'var(--joplin-color3, #0078ff)', cursor: 'pointer', padding: '0', textAlign: 'center', '&:hover': { backgroundColor: 'var(--joplin-color3, #0078ff)', color: '#fff', borderColor: 'var(--joplin-color3, #0078ff)', }, }, // Context menu [`& .${CTX}`]: { position: 'fixed', backgroundColor: 'var(--joplin-background-color, #fff)', border: '1px solid var(--joplin-divider-color, #ccc)', borderRadius: '6px', boxShadow: '0 4px 16px rgba(0,0,0,0.18)', zIndex: '100', minWidth: '190px', padding: '4px 0', fontSize: '13px', '& > div': { padding: '6px 14px', cursor: 'pointer', whiteSpace: 'nowrap', '&:hover': { backgroundColor: 'var(--joplin-background-color-hover3, #f0f0f0)', }, }, }, }); // View plugin that tags table widget containers with a class when the // document selection fully covers their range, so they visually pick up // the selection halo (drag-select, Ctrl+A, etc.) like other text would. const selectionHighlight = ViewPlugin.fromClass(class { public constructor(view: EditorView) { this.update_(view); } public update(update: ViewUpdate) { if (update.selectionSet || update.docChanged || update.viewportChanged) { this.update_(update.view); } } private update_(view: EditorView) { const sel = view.state.selection.main; const containers = view.dom.querySelectorAll(`.${W}`); for (const c of containers) { const from = Number(c.dataset.from); const to = Number(c.dataset.to); if (Number.isNaN(from) || Number.isNaN(to)) continue; const covered = sel.from <= from && sel.to >= to && sel.from !== sel.to; c.classList.toggle('cm-tw-selected', covered); } } }); // View plugin that highlights table cells whose text contains the active // search query, and scrolls the table's internal horizontal overflow so // the cell containing the current match (the document selection) is // visible. Block-replaced widgets normally swallow CM6's match // highlights, so this brings basic search feedback to tables. const searchHighlight = ViewPlugin.fromClass(class { private lastQuerySig_ = ''; public constructor(view: EditorView) { this.update_(view, true); } public update(update: ViewUpdate) { const q = getSearchQuery(update.state); const open = searchPanelOpen(update.state); const sig = `${open ? '1' : '0'}|${q.search}|${q.caseSensitive}|${q.regexp}|${q.wholeWord}|${q.valid}`; const queryChanged = sig !== this.lastQuerySig_; if (queryChanged) this.lastQuerySig_ = sig; if (queryChanged || update.selectionSet || update.docChanged || update.viewportChanged) { this.update_(update.view, queryChanged); } } private buildMatcher(q: SearchQuery): ((s: string)=> boolean) | null { if (!q.valid || !q.search) return null; const flags = q.caseSensitive ? 'g' : 'gi'; try { const pattern = q.regexp ? q.search : q.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wrapped = q.wholeWord ? `\\b(?:${pattern})\\b` : pattern; const re = new RegExp(wrapped, flags); return (s: string) => { re.lastIndex = 0; return re.test(s); }; } catch (_) { return null; } } private update_(view: EditorView, scrollIfActive = false) { const q = getSearchQuery(view.state); // Treat search as inactive when the panel is closed, so closing the // panel clears the cell highlights even if the query string is // retained in state. const matcher = searchPanelOpen(view.state) ? this.buildMatcher(q) : null; const sel = view.state.selection.main; const containers = view.dom.querySelectorAll(`.${W}`); for (const container of containers) { const from = Number(container.dataset.from); const to = Number(container.dataset.to); const selInTable = !Number.isNaN(from) && sel.from >= from && sel.to <= to; let firstMatch: HTMLElement | null = null; const cells = container.querySelectorAll('.cm-tw-text'); for (const cell of cells) { const text = cell.textContent ?? ''; const isMatch = matcher ? matcher(text) : false; cell.classList.toggle('cm-tw-match', isMatch); if (isMatch && !firstMatch) firstMatch = cell; } // When search lands the document selection inside this table, // nudge the table's internal horizontal overflow so a matching // cell is at least partially visible. if (scrollIfActive && selInTable && firstMatch) { firstMatch.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } } } }); // ===================== EXTENSION ===================== const renderTables = [ tableTheme, selectionHighlight, searchHighlight, EditorView.domEventHandlers({ mousedown: (event) => { if ((event.target as Element).closest(`.${W}`)) return true; return false; }, }), makeBlockReplaceExtension({ hideWhenContainsSelection: false, atomic: true, createDecoration: (node: SyntaxNodeRef, state: EditorState) => { if (node.name !== 'TableHeader') return null; const startLine = state.doc.lineAt(node.from); let endLine = startLine; for (let n = startLine.number + 1; n <= state.doc.lines; n++) { const l = state.doc.line(n); if (l.text.trim().startsWith('|') || l.text.includes('|')) { endLine = l; } else { break; } } const text = state.doc.sliceString(startLine.from, endLine.to); if (!parseTable(text)) return null; return new TableWidget(text, startLine.from, endLine.to); }, getDecorationRange: (node: SyntaxNodeRef, state: EditorState) => { if (node.name !== 'TableHeader') return null; const startLine = state.doc.lineAt(node.from); let endLine = startLine; for (let n = startLine.number + 1; n <= state.doc.lines; n++) { const l = state.doc.line(n); if (l.text.trim().startsWith('|') || l.text.includes('|')) { endLine = l; } else { break; } } return [startLine.from, endLine.to]; }, }), ]; export default renderTables;