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}
-
+
+