Do not let a table be the end of a note

A table, code block, quote or list as the last element of a
contenteditable element is a dead end: there is no node after it to put
the caret in, and no key that makes one, so the note simply cannot be
continued below it. Every engine behaves this way. The editor now keeps
an empty paragraph after such a block — it serializes to nothing, so it
never reaches the file, which a test pins down.

Tables got the rest of what they were missing while the cause was in
view: Tab walks the cells and a Tab out of the last one adds a row, the
toolbar button adds a row when the cursor is already in a table (a phone
has no Tab key, and it renames itself so it says which it will do), and
Ctrl/Cmd+Enter opens a paragraph after whatever block the cursor is in.
Empty cells are given a break, because a `<td></td>` with nothing in it
cannot be clicked into in Gecko — a blank cell was uneditable.

Driven in Firefox against a page that reproduces the dead end first.
This commit is contained in:
MechaCat02
2026-09-20 13:06:25 +02:00
parent 534b1b0f58
commit c259d97f04
3 changed files with 148 additions and 4 deletions

View File

@@ -58,6 +58,7 @@ export function createEditor(options) {
setMode(faithful ? preferred : 'source', { silent: true }); setMode(faithful ? preferred : 'source', { silent: true });
source.value = value; source.value = value;
rich.innerHTML = markdownToHtml(value); rich.innerHTML = markdownToHtml(value);
ensureTrailingParagraph();
updatePlaceholder(); updatePlaceholder();
return { faithful }; return { faithful };
} }
@@ -89,7 +90,10 @@ export function createEditor(options) {
if (next === mode) return; if (next === mode) return;
// Carry the text across, so a toggle never costs a word. // Carry the text across, so a toggle never costs a word.
if (next === 'source') source.value = markdownFromDom(rich); if (next === 'source') source.value = markdownFromDom(rich);
else rich.innerHTML = markdownToHtml(source.value); else {
rich.innerHTML = markdownToHtml(source.value);
ensureTrailingParagraph();
}
mode = next; mode = next;
rich.hidden = mode !== 'rich'; rich.hidden = mode !== 'rich';
@@ -112,6 +116,25 @@ export function createEditor(options) {
}); });
} }
/**
* A line after the last block, so there is somewhere to go.
*
* A table, a code block or a rule at the very end of a `contenteditable`
* element is a dead end: there is no node after it to put the caret in, and
* no key that makes one — the note simply cannot be continued. Every engine
* behaves this way, and every editor works around it the same way. The
* paragraph is empty, so it serializes to nothing and never reaches the file.
*/
const TRAILING_TRAP = /^(TABLE|PRE|HR|BLOCKQUOTE|UL|OL)$/;
function ensureTrailingParagraph() {
const last = rich.lastElementChild;
if (!last || !TRAILING_TRAP.test(last.nodeName)) return;
const paragraph = document.createElement('p');
paragraph.appendChild(document.createElement('br'));
rich.appendChild(paragraph);
}
function updatePlaceholder() { function updatePlaceholder() {
rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1); rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1);
} }
@@ -224,9 +247,21 @@ export function createEditor(options) {
else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a>&nbsp;'); else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a>&nbsp;');
} }
/**
* A table, or one more row of the table already under the cursor.
*
* Two jobs on one button because a phone has no Tab key, and adding a row is
* what anyone wants far more often than a second table inside the first.
* `reflect` renames the button so it says which one it will do.
*/
function insertTable() { function insertTable() {
const head = '<tr><th>&nbsp;</th><th>&nbsp;</th></tr>'; const table = tableAt();
const row = '<tr><td>&nbsp;</td><td>&nbsp;</td></tr>'; if (table) {
addRow(table);
return;
}
const head = '<tr><th><br></th><th><br></th></tr>';
const row = '<tr><td><br></td><td><br></td></tr>';
document.execCommand( document.execCommand(
'insertHTML', 'insertHTML',
false, false,
@@ -234,9 +269,54 @@ export function createEditor(options) {
); );
} }
function cellAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && (node.nodeName === 'TD' || node.nodeName === 'TH')) return node;
node = node.parentNode;
}
return undefined;
}
function tableAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && node.nodeName === 'TABLE') return node;
node = node.parentNode;
}
return undefined;
}
/** One more row, as wide as the table, with the caret in its first cell. */
function addRow(table) {
const rows = table.querySelectorAll('tr');
const width = Math.max(1, ...Array.from(rows, (row) => row.children.length));
const body = table.querySelector('tbody') ?? table;
const row = document.createElement('tr');
for (let i = 0; i < width; i++) {
const cell = document.createElement('td');
// An empty cell with nothing in it cannot be clicked into in Gecko;
// the break gives the caret somewhere to stand.
cell.appendChild(document.createElement('br'));
row.appendChild(cell);
}
body.appendChild(row);
placeCaret(row.firstElementChild);
}
function placeCaret(node) {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// --- input ----------------------------------------------------------- // --- input -----------------------------------------------------------
function notify() { function notify() {
ensureTrailingParagraph();
updatePlaceholder(); updatePlaceholder();
onInput(); onInput();
} }
@@ -297,6 +377,37 @@ export function createEditor(options) {
return; return;
} }
} }
// Tab walks the cells, and a Tab out of the last one adds a row. This is
// what every table anywhere does, and without it the only way to add a
// row on a keyboard would be the toolbar.
if (event.key === 'Tab' && !modifier) {
const cell = cellAt();
if (cell) {
event.preventDefault();
const cells = Array.from(cell.closest('table').querySelectorAll('th, td'));
const next = cells[cells.indexOf(cell) + (event.shiftKey ? -1 : 1)];
if (next) placeCaret(next);
else if (!event.shiftKey) addRow(cell.closest('table'));
notify();
return;
}
}
// The way out of anything: a new paragraph after the block the cursor is
// in, however deep in a table or a quote it sits.
if (event.key === 'Enter' && modifier) {
event.preventDefault();
let block = selectionNode();
while (block && block.parentNode !== rich) block = block.parentNode;
const paragraph = document.createElement('p');
paragraph.appendChild(document.createElement('br'));
if (block) block.after(paragraph);
else rich.appendChild(paragraph);
placeCaret(paragraph);
notify();
return;
}
// Enter at the end of a task item continues the list as tasks; the // Enter at the end of a task item continues the list as tasks; the
// browser would give the new item no box. // browser would give the new item no box.
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
@@ -358,6 +469,14 @@ export function createEditor(options) {
const button = toolbar.querySelector('[data-command="' + name + '"]'); const button = toolbar.querySelector('[data-command="' + name + '"]');
if (button) button.setAttribute('aria-pressed', String(Boolean(active))); if (button) button.setAttribute('aria-pressed', String(Boolean(active)));
} }
const table = toolbar.querySelector('[data-command="table"]');
if (table) {
const inside = Boolean(tableAt());
const label = inside ? 'Zeile anfügen' : 'Tabelle';
table.setAttribute('aria-label', label);
table.title = inside ? label + ' (oder Tab in der letzten Zelle)' : label;
}
} }
function query(command) { function query(command) {

View File

@@ -193,7 +193,12 @@ function tableToHtml(header, rows) {
const width = Math.max(header.length, ...rows.map((row) => row.length), 1); const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
const cells = (row, tag) => { const cells = (row, tag) => {
let out = ''; let out = '';
for (let i = 0; i < width; i++) out += '<' + tag + '>' + inlineToHtml(row[i] ?? '') + '</' + tag + '>'; for (let i = 0; i < width; i++) {
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
// clicked into, so a blank cell would be uneditable. It serializes
// back to an empty cell.
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + tag + '>';
}
return out; return out;
}; };
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join(''); const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');

View File

@@ -220,3 +220,23 @@ test('a task list keeps its state through the DOM the editor builds', () => {
'<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>'; '<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig'); assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig');
}); });
test('the editor\'s trailing escape line never reaches the file', () => {
// A table at the end of a contenteditable element is a dead end, so the
// editor keeps an empty paragraph after it. It must serialize to nothing,
// or every note with a table would grow a blank line on each save.
const html = markdownToHtml('| a | b |\n| --- | --- |\n| 1 | 2 |') + '<p><br></p>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('an empty cell stays an empty cell', () => {
// The editor puts a <br> in blank cells so the caret can reach them.
unchanged('| a | b |\n| --- | --- |\n| | 2 |');
assert.match(markdownToHtml('| a |\n| --- |\n| |'), /<td><br><\/td>/);
});
test('a row the editor appended round-trips', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
});