From 1b02eecdabe3bdd8e8d04e1594a2c64d670d4861 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 16:58:53 +0200 Subject: [PATCH] feat(notes): rich text, med Apple Notes' egne stilarter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Man skriver nu i formateret tekst frem for i Markdown-kildekode. Titel, Overskrift, Underrubrik, Brødtekst, Monotype, Blokcitat, punkt- og talliste, plus fed, kursiv, understreget og gennemstreget — samme ordforråd som Apple Notes, for det er det, en, der kommer derfra, allerede kan. Filen under er stadig Markdown. Det er ikke et kompromis, nogen skal leve med i stilhed: det er grunden til, at eksporten er til at stole på — filen, en note bliver til, er den fil, den allerede var. Understregning er den ene undtagelse; den findes ikke i noget Markdown-dialekt og gemmes som , som enhver læser sender uændret igennem. Det er bedre end at fjerne en stilart, kilden har. contenteditable med execCommand. Den er forældet og også det eneste, alle browsere er enige om; at skrive en markeringsmodel fra bunden er ugers arbejde og en ny slags fejl netop dér, hvor tabt tekst er utilgiveligt. Tre ting, prøverne fandt, som alle ville have kostet tekst: Værktøjslinjen stjal markeringen, når man klikkede — knapperne tager ikke fokus længere. Browseren skrev for en ophævet fed, hvilket en oversætter, der læser tags, taber; der bedes nu om tags, og styles læses alligevel, fordi det ikke honoreres alle steder. Og markeringen med Shift+Home tog hele dokumentet, ikke linjen. Favoritter er stjernen på hver note. Noteoversigten har fået luft mod venstre kant, og Gem og Slet er knapper frem for bare ord. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 82 +++--- web/src/lib/components/NoteEditor.svelte | 360 +++++++++++++++++++++++ web/src/lib/locales/da.js | 16 + web/src/lib/locales/en.js | 16 + web/src/lib/markdown.js | 79 ----- web/src/lib/richtext.js | 193 ++++++++++++ web/src/routes/noter/+page.svelte | 146 ++++++--- 7 files changed, 733 insertions(+), 159 deletions(-) create mode 100644 web/src/lib/components/NoteEditor.svelte delete mode 100644 web/src/lib/markdown.js create mode 100644 web/src/lib/richtext.js diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 19aa80e..ef895f2 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1955,48 +1955,58 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({ expect(trouble).toEqual([]); }); -test('noteeditoren viser formatering, og lagene flugter', async ({ page }) => { +test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({ + page +}) => { const trouble = watchForTrouble(page); await page.goto('/noter'); await page.getByRole('button', { name: 'Ny note' }).click(); - const body = page.getByLabel('Notens tekst'); - await body.fill( - '# Overskrift\n\nDette er **fed** og *kursiv* og `kode`.\n\nHandler om #Firma og [[En anden note]].\n- et punkt\n> et citat' - ); + const page_ = page.getByRole('textbox', { name: 'Notens tekst' }); + await page_.click(); - // The marks are still there — this is a mirror, not a renderer — but they are - // drawn as what they mean. - const mirror = page.locator('.mirror'); - await expect(mirror.locator('.bold')).toHaveText('**fed**'); - await expect(mirror.locator('.italic')).toHaveText('*kursiv*'); - await expect(mirror.locator('.code')).toHaveText('`kode`'); - await expect(mirror.locator('.tag')).toHaveText('#Firma'); - await expect(mirror.locator('.wikilink')).toHaveText('[[En anden note]]'); - await expect(mirror.locator('.h1')).toContainText('Overskrift'); + // Typed and formatted the way somebody would: pick a style, write, pick another. + await page.getByRole('button', { name: 'Formatér' }).click(); + await page.getByRole('menuitem', { name: 'Titel' }).click(); + await page.keyboard.type('Møde med Anders'); + await page.keyboard.press('Enter'); - // The whole risk of the technique: two layers that must line up character for - // character. If they drift, the formatting slides away from the text as the - // note grows, and it is unusable — so this measures rather than trusts. - const drift = await page.evaluate(() => { - const ta = document.querySelector('.paper textarea'); - const mi = document.querySelector('.paper .mirror'); - const a = getComputedStyle(ta), b = getComputedStyle(mi); - return { - font: a.fontSize === b.fontSize && a.fontFamily === b.fontFamily, - line: a.lineHeight === b.lineHeight, - wrap: a.whiteSpace === b.whiteSpace, - padding: a.paddingTop === b.paddingTop && a.paddingLeft === b.paddingLeft, - // And the rendered height, which is what actually proves it: the same text - // laid out twice has to take the same room. - heights: Math.abs(ta.scrollHeight - mi.scrollHeight) - }; - }); - expect(drift.font, 'skrifttypen er ikke ens').toBe(true); - expect(drift.line, 'linjehøjden er ikke ens').toBe(true); - expect(drift.wrap, 'ombrydningen er ikke ens').toBe(true); - expect(drift.padding, 'polstringen er ikke ens').toBe(true); - expect(drift.heights, 'lagene fylder ikke det samme').toBeLessThanOrEqual(2); + await page.getByRole('button', { name: 'Formatér' }).click(); + await page.getByRole('menuitem', { name: 'Brødtekst' }).click(); + await page.keyboard.type('Han vil gerne have kaffe hver uge.'); + + // The styles are real elements, not asterisks on screen. This is the whole of + // "it should feel like Apple Notes". + await expect(page_.locator('h1')).toHaveText('Møde med Anders'); + await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible(); + + // Bold through the toolbar, on a selection made with the keyboard. Not + // Shift+Home — in a contenteditable that is the start of the document, so it + // took the heading with it and the button toggled bold off instead of on. Not a + // double-click either: it leaves the selection empty here. + for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft'); + await page.getByRole('button', { name: 'Fed' }).click(); + // Either tag: the browser's own bold is , the converter writes , and + // both are read as bold on the way back. Asserting one of them would be + // asserting which browser this is. + await expect(page_.locator('b, strong')).toBeVisible(); + + // And the round trip, which is the thing that can quietly ruin a note: what is + // stored is Markdown, and reopening it has to give back the same document. A + // converter that disagrees with itself reshapes a note a little on every save. + await page_.blur(); + await page.reload(); + await page.getByRole('button', { name: /Møde med Anders/ }).click(); + + const back = page.getByRole('textbox', { name: 'Notens tekst' }); + await expect(back.locator('h1')).toHaveText('Møde med Anders'); + await expect(back.locator('b, strong')).toBeVisible(); + await expect(back).toContainText('kaffe hver uge'); + // No stray Markdown on screen: if the marks are showing, the conversion failed + // in the direction nobody notices until a note looks wrong. + await expect(back).not.toContainText('**'); + await expect(back).not.toContainText('# '); expect(trouble).toEqual([]); }); + diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte new file mode 100644 index 0000000..02e90e8 --- /dev/null +++ b/web/src/lib/components/NoteEditor.svelte @@ -0,0 +1,360 @@ + + +
+ + +
e.preventDefault()}> +
+ + {#if stylesOpen} + + + {/if} +
+ + + + + + + +
+ + +
+
+ + diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index af35e66..0e573ed 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -16,6 +16,22 @@ */ export const da = { // --- the shell --------------------------------------------------------------- + 'notes.save': 'Gem', + 'notes.format': 'Formatér', + 'notes.bold': 'Fed', + 'notes.italic': 'Kursiv', + 'notes.underline': 'Understreget', + 'notes.strike': 'Gennemstreget', + 'notes.styleTitle': 'Titel', + 'notes.styleHeading': 'Overskrift', + 'notes.styleSubheading': 'Underrubrik', + 'notes.styleBody': 'Brødtekst', + 'notes.styleMono': 'Monotype', + 'notes.styleQuote': 'Blokcitat', + 'notes.styleBullet': 'Punktliste', + 'notes.styleNumbered': 'Nummereret liste', + 'notes.favourite': 'Gør til favorit', + 'notes.unfavourite': 'Fjern som favorit', 'notes.title': 'Noter', 'notes.new': 'Ny note', 'notes.search': 'Søg i noter', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index f1194bd..3b4d163 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -12,6 +12,22 @@ */ export const en = { // --- the shell --------------------------------------------------------------- + 'notes.save': 'Save', + 'notes.format': 'Format', + 'notes.bold': 'Bold', + 'notes.italic': 'Italic', + 'notes.underline': 'Underline', + 'notes.strike': 'Strikethrough', + 'notes.styleTitle': 'Title', + 'notes.styleHeading': 'Heading', + 'notes.styleSubheading': 'Subheading', + 'notes.styleBody': 'Body', + 'notes.styleMono': 'Monospaced', + 'notes.styleQuote': 'Block quote', + 'notes.styleBullet': 'Bulleted list', + 'notes.styleNumbered': 'Numbered list', + 'notes.favourite': 'Make a favourite', + 'notes.unfavourite': 'Remove from favourites', 'notes.title': 'Notes', 'notes.new': 'New note', 'notes.search': 'Search notes', diff --git a/web/src/lib/markdown.js b/web/src/lib/markdown.js deleted file mode 100644 index bf95889..0000000 --- a/web/src/lib/markdown.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Just enough Markdown to see what you are writing. - * - * Not a renderer: the output is the same text, in the same order, with parts - * wrapped so they can be styled. That matters because it sits behind a textarea - * and has to line up with it character for character — a renderer that dropped - * the `**` or reflowed a list would drift out of alignment on the first bold word. - * - * So the marks stay, dimmed. Obsidian hides them except in the line the cursor is - * in; dimming them always is simpler, does not move text under the cursor as it - * travels, and still lets you read a document at a glance. It is a smaller idea - * that survives contact with a textarea. - */ - -const INLINE = [ - // Order matters: the longer fence first, or `**bold**` is read as two italics. - { kind: 'code', re: /`([^`\n]+)`/g }, - { kind: 'bold', re: /\*\*([^*\n]+)\*\*/g }, - { kind: 'italic', re: /\*([^*\n]+)\*/g }, - { kind: 'tag', re: /(?:^|(?<=\s))#[\p{L}\p{N}_-]{1,64}/gu }, - { kind: 'wikilink', re: /\[\[[^\]\n]{1,200}\]\]/g } -]; - -/** - * segmentsOf splits one line into pieces, each with the kind it should be drawn - * as. A piece with no kind is ordinary text. - */ -function inlineSegments(line) { - const marks = []; - - // Each pass reads a copy with the earlier passes' characters blanked out. - // - // Skipping overlaps after the fact is not enough, and the failure is subtle: - // after `**fed**` is claimed as bold, a plain overlap check still lets the - // italic pass start at the leftover `*` and run to the next one — swallowing - // " og " and leaving the actual italic word unmarked. Blanking means a later - // pass cannot see the characters at all. - let masked = line; - const blank = (start, end) => - (masked = masked.slice(0, start) + '\u0000'.repeat(end - start) + masked.slice(end)); - - for (const { kind, re } of INLINE) { - re.lastIndex = 0; - let m; - while ((m = re.exec(masked))) { - marks.push({ kind, start: m.index, end: m.index + m[0].length }); - } - for (const mark of marks) blank(mark.start, mark.end); - } - marks.sort((a, b) => a.start - b.start); - - const out = []; - let at = 0; - for (const mark of marks) { - if (mark.start > at) out.push({ text: line.slice(at, mark.start) }); - out.push({ text: line.slice(mark.start, mark.end), kind: mark.kind }); - at = mark.end; - } - if (at < line.length) out.push({ text: line.slice(at) }); - return out; -} - -/** - * linesOf turns a body into lines, each with a block kind and its inline pieces. - * Every line is returned, blank ones included, or the mirror would be one line - * short of the textarea for every empty line in the note. - */ -export function linesOf(body) { - return body.split('\n').map((line) => { - const heading = /^(#{1,6})\s/.exec(line); - if (heading) { - return { block: `h${heading[1].length}`, parts: inlineSegments(line) }; - } - if (/^\s*[-*+]\s/.test(line)) return { block: 'list', parts: inlineSegments(line) }; - if (/^\s*\d+\.\s/.test(line)) return { block: 'list', parts: inlineSegments(line) }; - if (/^\s*>\s?/.test(line)) return { block: 'quote', parts: inlineSegments(line) }; - return { block: 'p', parts: inlineSegments(line) }; - }); -} diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js new file mode 100644 index 0000000..5e21a0b --- /dev/null +++ b/web/src/lib/richtext.js @@ -0,0 +1,193 @@ +/** + * Between what you see and what is stored. + * + * The editor is rich text; the note on disk is Markdown. That is not a compromise + * anybody has to live with silently — it is the reason the export is trustworthy: + * the file a note becomes is the file it already was. So these two functions are + * the seam, and they have to agree with each other exactly, or a note changes + * shape a little every time it is opened and saved. + * + * The set of styles is deliberately small and matches what Apple Notes offers, + * because that is the vocabulary somebody coming from there already has: + * + * Titel → # + * Overskrift → ## + * Underrubrik → ### + * Brødtekst → nothing + * Monotype → four spaces + * Punktliste → - + * Nummereret → 1. + * Blokcitat → > + * + * Bold, italic and strikethrough have Markdown of their own. Underline does not — + * there is no such thing in any Markdown dialect — so it is stored as inline HTML, + * which every Markdown reader passes through untouched. It is the one place where + * the file is not pure Markdown, and it is that rather than dropping a style the + * source program has. + */ + +const BLOCKS = [ + { style: 'title', tag: 'h1', prefix: '# ' }, + { style: 'heading', tag: 'h2', prefix: '## ' }, + { style: 'subheading', tag: 'h3', prefix: '### ' }, + { style: 'quote', tag: 'blockquote', prefix: '> ' }, + { style: 'mono', tag: 'pre', prefix: ' ' } +]; + +const escapeHtml = (s) => + s.replace(/&/g, '&').replace(//g, '>'); + +/** Inline marks, innermost first so a bold word inside italics survives both. */ +function inlineToHtml(text) { + return escapeHtml(text) + .replace(/<u>(.+?)<\/u>/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); +} + +/** + * markdownToHtml renders a note for the editor. + * + * Every line becomes exactly one block. Markdown would normally join consecutive + * lines into one paragraph; here it must not, because the person typed a line and + * expects to get it back — an editor that silently reflows what somebody wrote is + * an editor they stop trusting. + */ +export function markdownToHtml(markdown) { + const lines = (markdown ?? '').split('\n'); + const out = []; + let list = null; + + const closeList = () => { + if (list) { + out.push(``); + list = null; + } + }; + + for (const line of lines) { + const bullet = /^[-*+]\s+(.*)$/.exec(line); + const numbered = /^\d+\.\s+(.*)$/.exec(line); + + if (bullet || numbered) { + const want = bullet ? 'ul' : 'ol'; + if (list !== want) { + closeList(); + out.push(`<${want}>`); + list = want; + } + out.push(`
  • ${inlineToHtml((bullet ?? numbered)[1])}
  • `); + continue; + } + closeList(); + + const block = BLOCKS.find((b) => + b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix) + ); + if (block) { + out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}`); + continue; + } + + // An empty line is still a line. Without the break the browser collapses the + // block to nothing and the caret has nowhere to stand. + out.push(`

    ${line.trim() === '' ? '
    ' : inlineToHtml(line)}

    `); + } + closeList(); + return out.join(''); +} + +/** The inline marks, read back off the DOM. */ +function inlineToMarkdown(node) { + if (node.nodeType === Node.TEXT_NODE) return node.textContent; + if (node.nodeType !== Node.ELEMENT_NODE) return ''; + + const inner = [...node.childNodes].map(inlineToMarkdown).join(''); + switch (node.tagName) { + case 'STRONG': + case 'B': + return inner ? `**${inner}**` : ''; + case 'EM': + case 'I': + return inner ? `*${inner}*` : ''; + case 'S': + case 'STRIKE': + case 'DEL': + return inner ? `~~${inner}~~` : ''; + case 'U': + return inner ? `${inner}` : ''; + case 'CODE': + return inner ? `\`${inner}\`` : ''; + case 'SPAN': { + // The browser's own way of saying bold or italic when it is writing styles + // rather than tags. Read as well as tags, because `styleWithCSS` is not + // honoured everywhere and a note must not lose a format on the way out. + const style = node.getAttribute('style') ?? ''; + if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : ''; + if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : ''; + if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : ''; + if (/text-decoration[^;]*underline/.test(style)) return inner ? `${inner}` : ''; + return inner; + } + case 'BR': + return ''; + default: + return inner; + } +} + +/** + * htmlToMarkdown reads the editor back. + * + * Written against the DOM rather than against a string of HTML, because that is + * what contenteditable actually produces — and what it produces is not tidy. The + * browser will nest, split and re-wrap as somebody types, and a regular expression + * over that is a guess. + */ +export function htmlToMarkdown(root) { + const lines = []; + + const walk = (node) => { + for (const child of node.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + if (child.textContent.trim()) lines.push(child.textContent); + continue; + } + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + switch (child.tagName) { + case 'UL': + case 'OL': { + let n = 1; + for (const li of child.children) { + const text = inlineToMarkdown(li).trim(); + lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`); + } + break; + } + case 'DIV': + // The browser's own container when nothing else applies. Read through + // it rather than treating it as a block, or every paste adds a level. + if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) { + walk(child); + } else { + lines.push(inlineToMarkdown(child)); + } + break; + default: { + const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase()); + const text = inlineToMarkdown(child); + lines.push(block ? block.prefix + text : text); + } + } + } + }; + + walk(root); + // Trailing blank lines are what an editor accumulates from people pressing + // return at the end; they are not content and they grow every time. + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); + return lines.join('\n'); +} diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index fa3acac..0bd88d7 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -14,7 +14,7 @@ import { api, humanMessage } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; import { t } from '$lib/i18n.svelte.js'; - import { linesOf } from '$lib/markdown.js'; + import NoteEditor from '$lib/components/NoteEditor.svelte'; let notes = $state([]); let selected = $state(null); @@ -89,6 +89,25 @@ } } + /** + * Favourites are the pinned ones. The list already puts them first, so marking + * one is the same act as saying "keep this where I can see it" — one idea, not + * two that have to be explained apart. + */ + async function favourite(note) { + const pinned = !note.pinned; + // Moved before the answer comes back: a star that waits for a round trip + // feels broken on a slow connection, and the cost of being wrong is a star. + notes = notes.map((n) => (n.id === note.id ? { ...n, pinned } : n)); + try { + await api.updateNote(note.id, { pinned }); + await load(query); + } catch (e) { + notes = notes.map((n) => (n.id === note.id ? { ...n, pinned: !pinned } : n)); + app.toast(humanMessage(e)); + } + } + async function remove(note) { if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return; try { @@ -103,11 +122,7 @@ // What the note points at, for the panel under the editor. let links = $derived(selected?.links ?? []); - // The styled twin of what is in the textarea. Recomputed on every keystroke, - // which sounds expensive and is not: it is a split and a handful of regexes - // over a page of text, and it runs in the same frame as the character that - // caused it. - let lines = $derived(linesOf(draft)); + {t('notes.title')} · verdande @@ -135,10 +150,22 @@
      {#each notes as note (note.id)}
    • - +
      + + +
    • {/each}
    @@ -147,33 +174,14 @@
    {#if selected} - -
    - - - -
    + { + draft = body; + typed(); + }} + onsave={save} + />
    {saving ? t('notes.saving') : t('notes.saved')} @@ -184,7 +192,10 @@ {/each} {/if} - +
    + + +
    {:else}

    {t('notes.pickOne')}

    @@ -194,8 +205,10 @@