mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Desktop: Render inline markdown formatting inside table cells (#15437)
This commit is contained in:
1 parent
e78e108bbd
commit
f4ad0b399f
5 files changed
+422
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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, '&')
|
||||
.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 <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) {
|
||||
|
||||
@@ -296,3 +296,4 @@ readdir
|
||||
keybinds
|
||||
unobserve
|
||||
querystring
|
||||
msgbox
|
||||
Reference in new issue
Block a user