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 = '
';
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] ?? '') + '' + tag + '>';
+ 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] ?? '') || ' ') + '' + tag + '>';
+ }
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 = '
a
b
' +
+ '
1
2
';
+ assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
+});