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| | |'); +});