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