Desktop: Render inline markdown formatting inside table cells (#15437)

This commit is contained in:
Laurent Cozic authored and GitHub committed 2026-05-15 23:07:56 +01:00
1 parent e78e108bbd
commit f4ad0b399f
5 files changed
+422 -28

No files matched your search

+1
View File
@@ -1111,6 +1111,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderTables.test.js
packages/editor/CodeMirror/extensions/rendering/renderTables.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
+1
View File
@@ -1088,6 +1088,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderTables.test.js
packages/editor/CodeMirror/extensions/rendering/renderTables.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
@@ -0,0 +1,128 @@
import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import createTestEditor from '../../testing/createTestEditor';
import renderTables, { renderInlineMarkdown } from './renderTables';
const createEditor = async (initialMarkdown: string) => {
return await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
['TableHeader'],
[renderTables],
);
};
const findCellTextDivs = (editor: EditorView) => {
return editor.dom.querySelectorAll<HTMLElement>('.cm-tw-text');
};
const focusCell = (cell: HTMLElement) => {
cell.dispatchEvent(new Event('focus'));
};
describe('renderTables', () => {
test.each([
{ input: 'plain text', expected: 'plain text', inner: '' },
{ input: '**bold**', expected: 'bold', inner: '<strong>bold</strong>' },
{ input: '__bold__', expected: 'bold', inner: '<strong>bold</strong>' },
{ input: '*italic*', expected: 'italic', inner: '<em>italic</em>' },
{ input: '_italic_', expected: 'italic', inner: '<em>italic</em>' },
{ input: '`code`', expected: 'code', inner: '<code>code</code>' },
{ input: '~~strike~~', expected: 'strike', inner: '<del>strike</del>' },
{ input: '[label](https://example.com)', expected: 'label', inner: '<a href="https://example.com">label</a>' },
{ input: 'a **b** c', expected: 'a b c', inner: 'a <strong>b</strong> c' },
// Escaped pipes are unescaped for display.
{ input: 'a \\| b', expected: 'a | b', inner: 'a | b' },
])('renderInlineMarkdown should render $input', ({ input, expected, inner }) => {
const div = document.createElement('div');
renderInlineMarkdown(div, input);
expect(div.textContent).toBe(expected);
if (inner) expect(div.innerHTML).toBe(inner);
});
test.each([
'foo_bar_baz',
'my_var_name',
'a*b*c',
'snake_case_identifier',
])('renderInlineMarkdown should not treat intra-word * or _ as emphasis: %s', (input) => {
const div = document.createElement('div');
renderInlineMarkdown(div, input);
expect(div.querySelector('em')).toBeNull();
expect(div.textContent).toBe(input);
});
test('renderInlineMarkdown should treat literal <br> as a line break', () => {
const div = document.createElement('div');
renderInlineMarkdown(div, 'line1<br>line2');
expect(div.querySelectorAll('br')).toHaveLength(1);
expect(div.textContent).toBe('line1line2');
});
test('renderInlineMarkdown should not let raw HTML through', () => {
const div = document.createElement('div');
renderInlineMarkdown(div, '<script>alert(1)</script>');
expect(div.querySelector('script')).toBeNull();
expect(div.textContent).toBe('<script>alert(1)</script>');
});
test.each([
'[click](javascript:alert(1))',
'[click](JavaScript:alert(1))',
'[click](data:text/html,<script>alert(1)</script>)',
'[click](vbscript:msgbox)',
])('renderInlineMarkdown should strip dangerous href schemes: %s', (input) => {
const div = document.createElement('div');
renderInlineMarkdown(div, input);
// DOMPurify may keep the anchor element but must remove the unsafe
// href so clicking it does nothing.
const anchor = div.querySelector('a');
expect(anchor?.getAttribute('href')).toBeFalsy();
// And nothing executable should have leaked in.
expect(div.querySelector('script')).toBeNull();
});
test.each([
{ url: 'https://example.com', expected: 'https://example.com' },
{ url: 'http://example.com', expected: 'http://example.com' },
{ url: 'mailto:test@example.com', expected: 'mailto:test@example.com' },
{ url: '/relative/path', expected: '/relative/path' },
{ url: ':/abc1234567890def', expected: ':/abc1234567890def' },
])('renderInlineMarkdown should produce anchors for safe URLs: $url', ({ url, expected }) => {
const div = document.createElement('div');
renderInlineMarkdown(div, `[label](${url})`);
const a = div.querySelector('a');
expect(a).not.toBeNull();
expect(a!.getAttribute('href')).toBe(expected);
});
test('cells should render inline markdown when not focused', async () => {
const editor = await createEditor('| **bold** | *italic* |\n|---|---|\n| `code` | plain |');
const cells = findCellTextDivs(editor);
// 2 header cells + 2 body cells
expect(cells).toHaveLength(4);
expect(cells[0].querySelector('strong')?.textContent).toBe('bold');
expect(cells[1].querySelector('em')?.textContent).toBe('italic');
expect(cells[2].querySelector('code')?.textContent).toBe('code');
expect(cells[3].textContent).toBe('plain');
});
test('focusing a cell should swap rendered DOM for raw markdown source', async () => {
const editor = await createEditor('| **bold** | b |\n|---|---|\n| x | y |');
const cells = findCellTextDivs(editor);
// Sanity: rendered first.
expect(cells[0].querySelector('strong')).not.toBeNull();
focusCell(cells[0]);
// After focus the cell should show the raw markdown text, no <strong>.
expect(cells[0].querySelector('strong')).toBeNull();
expect(cells[0].textContent).toBe('**bold**');
});
test('cells should preserve markdown source in the underlying document', async () => {
// After mounting, the document source should be unchanged — the widget
// must not rewrite the markdown just because cells render formatting.
const source = '| **bold** | b |\n|---|---|\n| x | y |';
const editor = await createEditor(source);
expect(editor.state.doc.toString()).toBe(source);
});
});
@@ -8,6 +8,7 @@
import { EditorView, WidgetType } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
import sanitizeHtml from '../../../ProseMirror/utils/sanitizeHtml';
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
import { focus, blur } from '@joplin/lib/utils/focusHandler';
import {
@@ -28,6 +29,72 @@ const CTX = 'cm-tw-ctx';
// heights correctly for scroll position and coordinate mapping.
const tableHeightCache = new Map<string, number>();
// 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<number, { r: number; c: number }>();
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Render a small subset of inline markdown into DOM children appended to
// `parent`. Supports: **bold**, *italic* / _italic_, `code`, ~~strike~~,
// [label](url), and literal <br> 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 <br> for soft breaks.
const normalised = text.replace(/\\\|/g, '|');
const segments = normalised.split(/<br\s*\/?>/i);
const parts: string[] = [];
for (let s = 0; s < segments.length; s++) {
if (s > 0) parts.push('<br>');
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 = /\*\*([^*]+)\*\*|__([^_]+)__|(?<![A-Za-z0-9])\*([^*]+)\*(?![A-Za-z0-9])|(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])|`([^`]+)`|~~([^~]+)~~|\[([^\]]+)\]\(([^)\s]+)\)/g;
let lastIdx = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(segment)) !== null) {
if (m.index > lastIdx) {
parts.push(escapeHtml(segment.slice(lastIdx, m.index)));
}
if (m[1] !== undefined || m[2] !== undefined) {
parts.push(`<strong>${escapeHtml((m[1] ?? m[2])!)}</strong>`);
} else if (m[3] !== undefined || m[4] !== undefined) {
parts.push(`<em>${escapeHtml((m[3] ?? m[4])!)}</em>`);
} else if (m[5] !== undefined) {
parts.push(`<code>${escapeHtml(m[5])}</code>`);
} else if (m[6] !== undefined) {
parts.push(`<del>${escapeHtml(m[6])}</del>`);
} else {
parts.push(`<a href="${escapeHtml(m[8]!)}">${escapeHtml(m[7]!)}</a>`);
}
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,
@@ -80,7 +147,10 @@ class TableWidget extends WidgetType {
// 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.
private apply(view: EditorView, newTable: Table | null) {
// `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);
@@ -90,6 +160,7 @@ class TableWidget extends WidgetType {
const insert = needsBlankLine ? `${newText}\n` : newText;
view.dispatch({
changes: { from: this.from, to: this.to, insert },
userEvent,
});
}
@@ -123,23 +194,35 @@ class TableWidget extends WidgetType {
let scrollbarDragging = false;
let lastFocusedTextDiv: HTMLElement | null = null;
// Sync all dirty cells back to the table model (without dispatching).
// Must be called before any structural apply() so edits are not lost.
// 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) continue;
// Sanitise: newlines → <br>, pipes → escaped
if (td !== active) continue;
const v = (td.textContent || '').trim().replace(/\n/g, '<br>').replace(/\|/g, '\\|');
const isH = ri === 0;
const orig = isH
? table.header.cells[ci]?.content
: table.body[ri - 1]?.cells[ci]?.content;
if (v !== orig) {
if (isH) table.header.cells[ci].content = v;
else if (ri - 1 < table.body.length) table.body[ri - 1].cells[ci].content = v;
}
if (isH) table.header.cells[ci].content = v;
else if (ri - 1 < table.body.length) table.body[ri - 1].cells[ci].content = v;
}
}
};
@@ -155,12 +238,131 @@ class TableWidget extends WidgetType {
textDiv.classList.add('cm-tw-text');
textDiv.contentEditable = 'true';
textDiv.spellcheck = false;
// Display unescaped text — escaped pipes (\|) are shown as plain |
textDiv.textContent = text.replace(/\\\|/g, '|');
// 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);
// Sync CM cursor to this cell so toolbar commands work
// 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, '<br>').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. <img>) 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,
@@ -176,6 +378,7 @@ class TableWidget extends WidgetType {
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(() => {
@@ -187,20 +390,29 @@ class TableWidget extends WidgetType {
const orig = isHdr
? table.header.cells[c]?.content
: table.body[r - 1]?.cells[c]?.content;
if (v === orig) return;
// 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, '<br>').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 all dirty cells to markdown
syncDirtyCells();
this.apply(view, table);
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, '<br>').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);
};
@@ -215,6 +427,7 @@ class TableWidget extends WidgetType {
e.stopPropagation();
skipBlurSync = true;
cancelLiveSync();
// Sync all dirty cells into the table model first
syncDirtyCells();
@@ -260,6 +473,7 @@ class TableWidget extends WidgetType {
} 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));
@@ -387,6 +601,55 @@ class TableWidget extends WidgetType {
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) {
+1
View File
@@ -296,3 +296,4 @@ readdir
keybinds
unobserve
querystring
msgbox