From 534b1b0f58c898dc292e72c144734ee7785f4be7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 19 Sep 2026 21:24:42 +0200 Subject: [PATCH 1/2] Write notes as formatted text, store them as Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor was a textarea holding raw Markdown, which is the wrong thing to hand someone taking notes during a lesson: nobody types `##` and `**` while a teacher is talking. It now shows the note formatted and puts a toolbar above it — headings, bold, lists, tick boxes, quotes, links, tables — while the file on disk stays exactly what it was, because that is what the indexer reads and what outlives this app. `markdown.js` is the whole translation: `markdownToHtml` on the way in, `markdownFromDom` on the way out. The property that matters is that the round trip settles — one pass may tidy a note, a second must change nothing — because these notes are the only record of what was said in the room and there is nothing to restore a lossy save from. `editor.js` checks exactly that before opening a note formatted, and a note it cannot hold unchanged opens in the Markdown view and says so instead of being quietly reduced. No editor library: the content security policy allows no outside script and the app has no bundler, so this is `contenteditable` and `execCommand` with a tolerant serializer behind it — an element it does not model keeps its words and loses its tag. Pasted HTML is converted to Markdown before it reaches the document, which is the one place where sanitising and formatting are the same operation. Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather than a headless browser or a DOM dependency; the toolbar itself was driven by hand in Firefox. WebKit has still never run it. --- CLAUDE.md | 20 +- README.md | 5 + docs/DEPLOY-NOTES.md | 4 +- docs/NOTES.md | 35 +++ scripts/smoke.mjs | 18 +- src/http/app-page.ts | 17 +- src/http/app/app.css | 125 +++++++- src/http/app/app.js | 82 +++-- src/http/app/editor.js | 400 +++++++++++++++++++++++ src/http/app/index.html | 36 ++- src/http/app/markdown.js | 647 ++++++++++++++++++++++++++++++++++++++ test/app-markdown.test.ts | 222 +++++++++++++ test/mini-dom.ts | 112 +++++++ 13 files changed, 1690 insertions(+), 33 deletions(-) create mode 100644 src/http/app/editor.js create mode 100644 src/http/app/markdown.js create mode 100644 test/app-markdown.test.ts create mode 100644 test/mini-dom.ts diff --git a/CLAUDE.md b/CLAUDE.md index 050035e..0fcbed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,11 @@ are stale — they were last taken before the notes and class-register work, and could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live session has lapsed — check the container's keepalive log before suspecting code. +The editor's Markdown round trip is unit-tested; the **browser** side of +`editor.js` is not, because nothing here runs one. It was checked by hand in +Firefox against a page that drives the toolbar — WebKit, which is the engine on +the phone this is written on, has still never run it. + Store tests need a database and skip without one: `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on purpose — the generation/diff semantics are entirely SQL, so a mock would test @@ -145,7 +150,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync **files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs` and read relative to `import.meta.dirname` — real HTML, CSS and JS that an editor and a linter understand, which is also what the CSP requires, since it - forbids inline script. + forbids inline script. `app.js` is an ES **module**; a new asset must be added + to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s. +- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted + text and stored as Markdown, and these two are that translation. + `markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js` + drives a `contenteditable` element with `execCommand` (no library: the CSP + allows no outside script and the app has no bundler). **The round trip must + settle**: one pass may tidy a note, a second must change nothing, and + `editor.js` checks exactly that before opening a note formatted — a note that + fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers + it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a + lesson's notes to a lossy serializer is not a bug anyone can recover from. + Pasted HTML goes through Markdown before it reaches the document, which is + where sanitising and formatting are the same operation. - **`http/web-auth.ts`** — the app's login, which is a different kind of credential from everything else here: a password a person types, not a token a program was configured with. scrypt at startup, a signed `HttpOnly` / diff --git a/README.md b/README.md index 9cd9661..b81de63 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is indexed as its own lesson, so a search answers "my own note, Deutsch, 18.09.2026" rather than "Friday". +Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes, +quotes, links and tables come from a toolbar, and `MD` shows the Markdown +underneath when you want it. The file on disk stays Markdown either way — that +is what the index reads and what outlives the app. + It saves as you type, keeps a local copy of every keystroke for when the signal goes, and refuses a save that would overwrite a version it never saw. On a phone it adds to the home screen and opens standalone. diff --git a/docs/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md index 1a08a42..24a793d 100644 --- a/docs/DEPLOY-NOTES.md +++ b/docs/DEPLOY-NOTES.md @@ -12,7 +12,7 @@ should not make twice**, because changing it later means moving files by hand. | | | |---|---| | **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. | -| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | +| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | | **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. | Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes @@ -241,6 +241,8 @@ write them. | `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container | | Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app | | `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate | +| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` | +| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note | | Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` | | `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads | | `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes | diff --git a/docs/NOTES.md b/docs/NOTES.md index 358844a..551712e 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -87,6 +87,41 @@ thing I open in a free period". `add_note` — the save is refused and you are asked which version wins. It never silently overwrites. +### Writing, without typing Markdown + +The editor shows the note **formatted** — headings as headings, bold as bold, +tables as tables — and the toolbar above it writes the Markdown. Nobody types +`##` or `**` during a lesson. + +| Button | What it writes | +| --- | --- | +| `H2` | a lesson heading — the one that makes the lesson separately searchable | +| `H3` | a subheading inside a lesson | +| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) | +| `<>` | inline code (`Strg`/`Cmd` + E) | +| `• —` `1. —` | bullet and numbered lists; nest them with Tab | +| `☐` | a box to tick off | +| `❝` | a quote — the teacher's exact wording | +| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table | +| `MD` | the Markdown itself | + +`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A +paste from a web page or a PDF keeps its structure and loses its fonts, colours +and anything else that is not in the list above — pasted HTML is converted to +Markdown before it reaches the page, which is what keeps a copied page from +bringing its script along. + +**The file is still Markdown.** `MD` shows it and lets you edit it directly, +which is the way to write something the toolbar has no button for. There is no +underline, because Markdown cannot store one — `F` or `K` instead. + +Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly +lines up. Nothing is rewritten until you actually change something, and a note +whose formatting the view cannot hold unchanged **opens as Markdown** and says +so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds +that promise up: every construct in this document goes in and comes back out +unchanged. + **Einstellungen** holds the Schulcloud token: how long it has left, and the box to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud token set` and the older `/token` page do). It also shows the index's state and diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index af5186f..cbdc422 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -726,10 +726,22 @@ console.log('\n== web app =='); ); check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN)); - const assets = await Promise.all( - ['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)), - ); + // Every file the shell asks for, including the two modules the editor is + // made of: a missing one leaves a page that loads and cannot type. + const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest']; + const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`))); check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' ')); + check( + 'the editor\'s modules are served as JavaScript', + assets + .filter((_, index) => assetNames[index].endsWith('.js')) + .every((response) => /javascript/.test(response.headers.get('content-type') ?? '')), + assets.map((r) => r.headers.get('content-type')).join(' | '), + ); + check( + 'the shell loads the app as a module, so its imports resolve', + / + diff --git a/src/http/app/markdown.js b/src/http/app/markdown.js new file mode 100644 index 0000000..a4c5b6a --- /dev/null +++ b/src/http/app/markdown.js @@ -0,0 +1,647 @@ +/* + * Markdown in, formatted text out, and back again. + * + * The notes are Markdown files — that is what the indexer reads, what + * `subjectFromHeading` takes a lesson apart with, and what survives this + * project. The editor shows them as formatted text anyway, so this module is + * the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on + * the way back out to the file. + * + * Three properties matter more than completeness, because what passes through + * here is the only record of what was said in a lesson: + * + * - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*` + * becomes `_a_`, a ragged table lines up — but doing it again must change + * nothing. `editor.js` checks exactly that before it opens a note in + * formatted mode, and falls back to the Markdown view when it does not hold. + * - **Nothing is dropped.** An element this module does not model keeps its + * words and loses its tag. A note is better off plain than short. + * - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a + * construct it produced itself, so a note containing ` & nicht fett'); + assert.equal(html.includes(''), false); + assert.match(html, /<script>/); +}); + +test('markup the editor does not model keeps its words', () => { + // What a paste from a web page leaves behind: the tags mean nothing here, + // the text means everything. + const html = '

fett unterstrichen rot

'; + assert.equal(markdownFromDom(parseHtml(html)), '**fett** unterstrichen rot'); +}); + +test('a browser\'s own line divs become paragraphs', () => { + // contenteditable produces these on every Enter, in every engine. + assert.equal(markdownFromDom(parseHtml('
eins
zwei
')), 'eins\n\nzwei'); + assert.equal(markdownFromDom(parseHtml('

')), ''); +}); + +test('text that looks like Markdown is escaped, and comes back as text', () => { + unchanged('2 \\* 3 \\* 4'); + unchanged('\\- kein Listenpunkt'); + unchanged('\\# keine Überschrift'); + assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_'); +}); + +test('a whole day note survives unchanged', () => { + // The shape the app writes and the indexer reads back: one `##` per lesson, + // prose, a list, a subheading and a table underneath. + const note = [ + '## 1. Deutsch — 08:00–08:45 · MEI · R 204', + '', + 'Dreischritt: These, Argument mit Beleg, Fazit.', + '', + '### Aufbau', + '', + '- Gegenargument nicht vergessen', + ' - kam letztes Jahr in der Arbeit dran', + '- **Fazit** knapp halten', + '', + '## 2. LF07 — 08:50–09:35 · Sb · R 108', + '', + '| Präfix | Nutzbare Adressen |', + '| --- | --- |', + '| /24 | 254 |', + '| /25 | 126 |', + '', + '> Kommt so in der Arbeit dran.', + ].join('\n'); + unchanged(note); +}); + +test('the lesson headings the indexer keys on come back verbatim', () => { + // `lessonHeading` writes these and `subjectFromHeading` reads the subject + // back out of them. An editor that rewrote the dash or the separator would + // file a day's notes under nothing. + for (const heading of [ + '## 1. Deutsch — 08:00–08:45 · MEI · R 204', + '## 3. LF07 — 10:35–11:20 · Sb · R 108 (Vertretung)', + '## 5. Englisch — 12:15–13:00', + ]) { + unchanged(heading); + } +}); + +test('an empty note is empty, not a paragraph', () => { + assert.equal(markdownToHtml(''), ''); + assert.equal(back(''), ''); + assert.equal(back('\n\n \n'), ''); +}); + +test('a list the browser nested as a sibling keeps its items', () => { + // What several engines produce when Tab indents a bullet: the nested list + // beside the items rather than inside one. Skipping it would drop + // everything under it without a trace. + const html = ''; + assert.equal(markdownFromDom(parseHtml(html)), '- eins\n - eins a\n- zwei'); +}); + +test('an item whose text the browser wrapped in a div is still one line', () => { + assert.equal(markdownFromDom(parseHtml('')), '- eins'); + assert.equal(markdownFromDom(parseHtml('
  1. eins

')), '1. eins'); +}); + +test('what execCommand produces round-trips', () => { + // styleWithCSS is turned off, so bold and italic arrive as tags — but + // ``/``, not ``/``. + assert.equal(markdownFromDom(parseHtml('

fett und kursiv

')), '**fett** und _kursiv_'); + // A heading made by formatBlock, and the empty paragraph left behind. + assert.equal(markdownFromDom(parseHtml('

Deutsch


')), '## Deutsch'); +}); + +test('a task list keeps its state through the DOM the editor builds', () => { + const html = '
  • offen
  • ' + + '
  • fertig
'; + assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig'); +}); diff --git a/test/mini-dom.ts b/test/mini-dom.ts new file mode 100644 index 0000000..c5ffeec --- /dev/null +++ b/test/mini-dom.ts @@ -0,0 +1,112 @@ +/** + * Just enough DOM to run the notes editor's serializer under `node --test`. + * + * `markdownFromDom` walks a tree with `nodeType`, `nodeName`, `childNodes`, + * `textContent` and `getAttribute` — nothing else, and nothing that writes — + * which is what lets the round-trip be tested here rather than only in a + * browser. The round-trip is the part of the editor that can quietly destroy a + * lesson's notes, so testing it is not optional; adding a headless browser or a + * DOM library to do it would be a much larger dependency than these 60 lines. + * + * It parses only the HTML `markdownToHtml` emits: known tags, quoted + * attributes, no comments, no CDATA, no implied end tags. + */ + +export interface MiniNode { + nodeType: 1 | 3; + nodeName: string; + childNodes: MiniNode[]; + textContent: string; + getAttribute(name: string): string | null; +} + +const VOID = new Set(['BR', 'HR', 'INPUT', 'IMG', 'META', 'LINK']); +const TAG = /<(\/)?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/)?>/g; + +class Element implements MiniNode { + readonly nodeType = 1 as const; + readonly nodeName: string; + readonly childNodes: MiniNode[] = []; + private readonly attributes: Map; + + constructor(name: string, attributes: Map) { + this.nodeName = name.toUpperCase(); + this.attributes = attributes; + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(''); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name.toLowerCase()) ?? null; + } +} + +class Text implements MiniNode { + readonly nodeType = 3 as const; + readonly nodeName = '#text'; + readonly childNodes: MiniNode[] = []; + textContent: string; + + constructor(value: string) { + this.textContent = value; + } + + getAttribute(): null { + return null; + } +} + +/** A fragment whose `childNodes` are the parsed top-level nodes. */ +export function parseHtml(html: string): MiniNode { + const root = new Element('body', new Map()); + const stack: Element[] = [root]; + let index = 0; + + TAG.lastIndex = 0; + for (let match = TAG.exec(html); match; match = TAG.exec(html)) { + if (match.index > index) addText(stack.at(-1)!, html.slice(index, match.index)); + index = TAG.lastIndex; + + const name = match[2]!.toUpperCase(); + if (match[1]) { + // A close tag: unwind to it, ignoring one that was never opened. + const at = stack.findLastIndex((element) => element.nodeName === name); + if (at > 0) stack.length = at; + continue; + } + + const element = new Element(name, attributesOf(match[3] ?? '')); + stack.at(-1)!.childNodes.push(element); + if (!VOID.has(name) && !match[4]) stack.push(element); + } + if (index < html.length) addText(stack.at(-1)!, html.slice(index)); + + return root; +} + +function addText(parent: Element, value: string): void { + if (!value) return; + parent.childNodes.push(new Text(decode(value))); +} + +function attributesOf(source: string): Map { + const attributes = new Map(); + const pattern = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + for (let match = pattern.exec(source); match; match = pattern.exec(source)) { + // A bare attribute (`checked`) is present with an empty value, which is + // what the DOM reports too. + attributes.set(match[1]!.toLowerCase(), decode(match[2] ?? match[3] ?? match[4] ?? '')); + } + return attributes; +} + +function decode(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} From c259d97f041619476b5caf23087fbb9d5d0d178d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 20 Sep 2026 13:06:25 +0200 Subject: [PATCH 2/2] 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| | |'); +});