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 @@ @@ -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 @@