From 5757f06e7a7e9709948ff6da735e397d577062fe Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 13:38:18 +0200 Subject: [PATCH] feat(notes): man kan se hvad man skriver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatering vises, mens man skriver. Overskrifter, fed, kursiv, kode, citater, punkter, #tags og [[links]]. Et spejl bag et gennemsigtigt tekstfelt — samme greb som hurtig tilføjelse bruger til at vise, hvad parseren forstod. En contenteditable ville lade teksten style direkte og ville også ødelægge fortryd, autokorrektur og IME. Det er en dårlig byttehandel i det ene sted i programmet, hvor nogen skriver en halv time i træk. Mærkerne bliver stående, dæmpede. Obsidian skjuler dem uden for den linje, markøren står i; at dæmpe dem altid er en mindre idé, der ikke flytter tekst under markøren mens den bevæger sig — og den overlever mødet med et tekstfelt. Overskrifter skifter vægt og farve, aldrig størrelse: en større skrift ville fylde flere linjer end tekstfeltet gør, og så skred alt nedenunder. Hele risikoen ved grebet er, at de to lag skal flugte tegn for tegn. Prøven måler det frem for at stole på det: skrifttype, linjehøjde, ombrydning, polstring og den faktiske højde af de to lag med samme tekst i. Undervejs: kursiv-passet slugte " og " efter et fed-udtryk, fordi den løse stjerne stadig var synlig for det. Hvert pas læser nu en kopi, hvor de tidligere pas' tegn er blændet ud. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 46 +++++++++++ web/src/lib/markdown.js | 79 +++++++++++++++++++ web/src/routes/noter/+page.svelte | 125 ++++++++++++++++++++++++++---- 3 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/markdown.js diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 1e3dd18..19aa80e 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1954,3 +1954,49 @@ 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 }) => { + 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' + ); + + // 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'); + + // 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); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/markdown.js b/web/src/lib/markdown.js new file mode 100644 index 0000000..bf95889 --- /dev/null +++ b/web/src/lib/markdown.js @@ -0,0 +1,79 @@ +/** + * 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/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 3283293..fa3acac 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -14,6 +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'; let notes = $state([]); let selected = $state(null); @@ -101,6 +102,12 @@ // 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 @@ -140,14 +147,33 @@
{#if selected} - + +
+ + + +