**not bold**' },
])('renderInlineMarkdown should render $input', ({ input, expected, inner }) => {
const div = document.createElement('div');
renderInlineMarkdown(div, input);
@@ -202,4 +209,63 @@ describe('renderTables', () => {
}
});
+ test.each([
+ '**[label](https://example.com)**',
+ '**bold**',
+ '*italic*',
+ '`code`',
+ ])('focus/blur cycles should not degrade cell markdown: %s', async (cellSource) => {
+ jest.useFakeTimers();
+ let editor: EditorView | null = null;
+ try {
+ const markdown = `| a | b |\n|---|---|\n| ${cellSource} | y |`;
+ editor = await createEditor(markdown);
+ document.body.appendChild(editor.dom);
+
+ // Each cycle re-renders the cell without editing it.
+ for (let i = 0; i < 3; i++) {
+ const cell = findCellTextDivs(editor)[2];
+ focusCell(cell);
+ cell.dispatchEvent(new Event('blur'));
+ jest.advanceTimersByTime(200);
+ jest.runOnlyPendingTimers();
+
+ const rendered = findCellTextDivs(editor)[2];
+ expect(rendered.classList.contains('cm-tw-raw')).toBe(false);
+ }
+
+ expect(editor.state.doc.toString()).toContain(cellSource);
+ } finally {
+ editor?.destroy();
+ jest.useRealTimers();
+ }
+ });
+
+ test('a blurred cell should not write its rendered text back to the document', async () => {
+ jest.useFakeTimers();
+ let editor: EditorView | null = null;
+ try {
+ editor = await createEditor('| a | b |\n|---|---|\n| **[x](http://e.com)** | y |');
+ document.body.appendChild(editor.dom);
+
+ const cell = findCellTextDivs(editor)[2];
+ focusCell(cell);
+ cell.dispatchEvent(new Event('blur'));
+ jest.advanceTimersByTime(200);
+ jest.runOnlyPendingTimers();
+
+ // An input event on the now-rendered cell, as a stray mutation
+ // would fire, must not harvest its stripped text.
+ const rendered = findCellTextDivs(editor)[2];
+ rendered.dispatchEvent(new Event('input'));
+ jest.advanceTimersByTime(700);
+ jest.runOnlyPendingTimers();
+
+ expect(editor.state.doc.toString()).toContain('**[x](http://e.com)**');
+ } finally {
+ editor?.destroy();
+ jest.useRealTimers();
+ }
+ });
+
});
diff --git a/packages/editor/CodeMirror/extensions/rendering/renderTables.ts b/packages/editor/CodeMirror/extensions/rendering/renderTables.ts
index a668afe123..2339a4ea2a 100644
--- a/packages/editor/CodeMirror/extensions/rendering/renderTables.ts
+++ b/packages/editor/CodeMirror/extensions/rendering/renderTables.ts
@@ -27,6 +27,8 @@ const W = 'cm-tw';
const CELL = 'cm-tw-c';
const HDR = 'cm-tw-h';
const CTX = 'cm-tw-ctx';
+// Marks a cell holding raw source. Only these may be read back into the model.
+const RAW = 'cm-tw-raw';
// Cache for rendered table widget heights so CodeMirror can estimate
// heights correctly for scroll position and coordinate mapping.
@@ -59,45 +61,54 @@ const escapeHtml = (s: string): string => {
// 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.
+// Wrapper contents recurse so nested markup works (**[label](url)** is a bold
+// link). Code spans do not: their contents are literal in markdown.
+const inlineMarkdownToHtml = (segment: string): string => {
+ // 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 = /\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|(? lastIdx) {
+ parts.push(escapeHtml(segment.slice(lastIdx, m.index)));
+ }
+ if (m[1] !== undefined || m[2] !== undefined) {
+ parts.push(`${inlineMarkdownToHtml((m[1] ?? m[2])!)}`);
+ } else if (m[3] !== undefined || m[4] !== undefined) {
+ parts.push(`${inlineMarkdownToHtml((m[3] ?? m[4])!)}`);
+ } else if (m[5] !== undefined) {
+ parts.push(`${escapeHtml(m[5])}`);
+ } else if (m[6] !== undefined) {
+ parts.push(`${escapeHtml(m[5])}`);
- } else if (m[6] !== undefined) {
- parts.push(`