From c259d97f041619476b5caf23087fbb9d5d0d178d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 20 Sep 2026 13:06:25 +0200 Subject: [PATCH] Do not let a table be the end of a note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- src/http/app/editor.js | 125 +++++++++++++++++++++++++++++++++++++- src/http/app/markdown.js | 7 ++- test/app-markdown.test.ts | 20 ++++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/http/app/editor.js b/src/http/app/editor.js index 8dc71f8..f511572 100644 --- a/src/http/app/editor.js +++ b/src/http/app/editor.js @@ -58,6 +58,7 @@ export function createEditor(options) { setMode(faithful ? preferred : 'source', { silent: true }); source.value = value; rich.innerHTML = markdownToHtml(value); + ensureTrailingParagraph(); updatePlaceholder(); return { faithful }; } @@ -89,7 +90,10 @@ export function createEditor(options) { if (next === mode) return; // Carry the text across, so a toggle never costs a word. if (next === 'source') source.value = markdownFromDom(rich); - else rich.innerHTML = markdownToHtml(source.value); + else { + rich.innerHTML = markdownToHtml(source.value); + ensureTrailingParagraph(); + } mode = next; 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() { rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1); } @@ -224,9 +247,21 @@ export function createEditor(options) { else document.execCommand('insertHTML', false, '' + escapeHtml(href) + ' '); } + /** + * 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() { - const head = '  '; - const row = '  '; + const table = tableAt(); + if (table) { + addRow(table); + return; + } + const head = '

'; + const row = '

'; document.execCommand( 'insertHTML', 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 ----------------------------------------------------------- function notify() { + ensureTrailingParagraph(); updatePlaceholder(); onInput(); } @@ -297,6 +377,37 @@ export function createEditor(options) { 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 // browser would give the new item no box. if (event.key === 'Enter' && !event.shiftKey) { @@ -358,6 +469,14 @@ export function createEditor(options) { const button = toolbar.querySelector('[data-command="' + name + '"]'); 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) { diff --git a/src/http/app/markdown.js b/src/http/app/markdown.js index a4c5b6a..1b99a27 100644 --- a/src/http/app/markdown.js +++ b/src/http/app/markdown.js @@ -193,7 +193,12 @@ function tableToHtml(header, rows) { const width = Math.max(header.length, ...rows.map((row) => row.length), 1); const cells = (row, tag) => { let out = ''; - for (let i = 0; i < width; i++) out += '<' + tag + '>' + inlineToHtml(row[i] ?? '') + ''; + for (let i = 0; i < width; i++) { + // A break in an empty cell: a `` 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] ?? '') || '
') + ''; + } return out; }; const body = rows.map((row) => '' + cells(row, 'td') + '').join(''); diff --git a/test/app-markdown.test.ts b/test/app-markdown.test.ts index 052cba9..aac1f2d 100644 --- a/test/app-markdown.test.ts +++ b/test/app-markdown.test.ts @@ -220,3 +220,23 @@ test('a task list keeps its state through the DOM the editor builds', () => { '
  • 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 |') + '


    '; + assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |'); +}); + +test('an empty cell stays an empty cell', () => { + // The editor puts a
    in blank cells so the caret can reach them. + unchanged('| a | b |\n| --- | --- |\n| | 2 |'); + assert.match(markdownToHtml('| a |\n| --- |\n| |'), /
    <\/td>/); +}); + +test('a row the editor appended round-trips', () => { + const html = '' + + '
    ab
    12


    '; + assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |'); +});