diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 79d2bc6..5932d1d 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4797,6 +4797,24 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) = await expect(list.getByText('Fyrreskov syd')).toBeVisible(); await expect(list.getByText('Egeskov')).toHaveCount(0); + // Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede, + // og den blev læst igen hver gang noget ændrede sig — så et klik på en anden + // række satte `selectedId`, effekten så den uenig med adressen, og trak én + // tilbage til den første. Man kunne søge og ikke vælge. + await list.getByText('Fyrreskov syd').click(); + await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd'); + await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord'); + + // Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at + // åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger + // stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og + // huskede værdien, ville se den uændret og lade være med at åbne noget. Man + // kunne så ikke komme tilbage til den note, man kom fra. + await page.getByRole('button', { name: /Søg/ }).click(); + await palette.getByRole('textbox').fill('Fyrreskov nord'); + await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click(); + await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord'); + // Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` — // den skrives kun af navigation. Den første udgave skrev den ved hvert // tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se @@ -4923,6 +4941,105 @@ test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) expect(trouble).toEqual([]); }); +/** + * En punktliste kan have niveauer, og de overlever gemningen. + * + * Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var + * ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med + * Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to. + * + * Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown — + * men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger + * browseren den som søskende, og det gør de uens, blev grenen sprunget over uden + * lyd: punktet forsvandt ved gemningen. Begge former læses nu. + * + * Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad — + * det er turen gennem Markdown, der afgør, om den blev skrevet ned. + */ +test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + await nyNote(page); + await page.keyboard.type('Indkøb'); + await page.keyboard.press('Enter'); + await page.getByRole('button', { name: 'Formatér' }).click(); + await page.getByRole('menuitem', { name: 'Punktliste' }).click(); + await page.keyboard.type('Frugt'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Aebler'); + await page.keyboard.press('Tab'); + + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + // Niveauet er der med det samme: en liste inde i en liste. + await expect(ed.locator('ul ul li')).toHaveText(['Aebler']); + + const gemt = noteGemt(page); + await ed.blur(); + await gemt; + await page.reload(); + await page.getByRole('button', { name: /Indkøb/ }).click(); + + // Og efter turen gennem Markdown står det der stadig. + await expect(ed.locator('ul ul li')).toHaveText(['Aebler']); + await expect(ed.locator('ul > li').first()).toContainText('Frugt'); + + // Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør. + await ed.locator('ul ul li').click(); + await page.keyboard.press('Shift+Tab'); + await expect(ed.locator('ul ul li')).toHaveCount(0); + + expect(trouble).toEqual([]); +}); + +/** + * En kodeblok med flere linjer kommer tilbage med dem alle. + * + * Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren + * lægger ind, er et
, og `textContent` på et
er den tomme streng. Blokken + * blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne + * skrive flere linjer og ikke gemme dem. + * + * Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der — + * det er turen gennem Markdown, der taber dem, og det er præcis den, prøven + * ovenfor stopper lige før. + */ +test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + await nyNote(page); + await page.keyboard.type('Opskrift'); + await page.keyboard.press('Enter'); + await page.getByRole('button', { name: 'Formatér' }).click(); + await page.getByRole('menuitem', { name: 'Monotype' }).click(); + await page.keyboard.type('first line'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('second line'); + + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + const gemt = noteGemt(page); + await ed.blur(); + await gemt; + await page.reload(); + await page.getByRole('button', { name: /Opskrift/ }).click(); + + // Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som + // normaliserer mellemrum væk — og linjeskiftet ER det, der måles. + // + // Og på , ikke på
: kopiér-knappen ligger inde i blokken for at
+	// kunne sidde i hjørnet af den, så `textContent` på 
 har ordet "Kopiér"
+	// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
+	// en blocks tekst — bare fra den anden side.
+	const blok = ed.locator('pre');
+	await expect(blok).toHaveCount(1);
+	expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
+		'first line\nsecond line'
+	);
+
+	expect(trouble).toEqual([]);
+});
+
 /**
  * Et indsat billede kan ses i fuld størrelse og kopieres videre.
  *
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index e024c20..8831a5d 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -788,6 +788,17 @@
 		});
 	}
 
+	/**
+	 * Står markøren i en punktliste eller en nummereret liste?
+	 *
+	 * Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
+	 * den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
+	 * ikke blive uenige om, hvad der er en liste.
+	 */
+	const inList = () =>
+		document.queryCommandState('insertUnorderedList') ||
+		document.queryCommandState('insertOrderedList');
+
 	function onkeydown(event) {
 		// The suggestion list owns the arrows and return while it is open, the way
 		// every other completion does. Escape closes it without choosing.
@@ -816,6 +827,15 @@
 			}
 		}
 
+		// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
+		// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
+		// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
+		if (event.key === 'Tab' && inList()) {
+			event.preventDefault();
+			apply(event.shiftKey ? 'outdent' : 'indent');
+			return;
+		}
+
 		if (event.key === 'Enter' && !event.shiftKey) {
 			// A code block is the one place the browser keeps the caret inside on
 			// Enter rather than starting a new block. Step out of it ourselves, or a
diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js
index dba6677..caf10f2 100644
--- a/web/src/lib/richtext.js
+++ b/web/src/lib/richtext.js
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
 /** Whether an element holds blocks, in which case reading it as one line loses them. */
 const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
 
+/**
+ * Teksten i en kodeblok, med linjeskiftene i behold.
+ *
+ * `textContent` er ikke nok, og det er ikke en detalje: et 
har den tomme + * streng som textContent, og
er præcis det, browseren lægger ind, når man + * trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje — + * "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem. + * Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele + * tiden. + * + * Kopiér-knappen springes over. Den ligger inde i
 for at kunne sidde i
+ * hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
+ * gemning.
+ *
+ * Rekursivt, fordi indholdet ligger i et  inde i blokken: en filtrering over
+ * 
'ens egne børn ser kun det ene element og spørger det om en textContent,
+ * der allerede har tabt linjeskiftet.
+ */
+function codeText(node) {
+	let out = '';
+	for (const n of node.childNodes) {
+		if (n.nodeType !== Node.ELEMENT_NODE) {
+			out += n.textContent ?? '';
+			continue;
+		}
+		if (n.classList?.contains('copy')) continue;
+		if (n.tagName === 'BR') {
+			out += '\n';
+			continue;
+		}
+		out += codeText(n);
+	}
+	return out;
+}
+
 export function htmlToMarkdown(root) {
 	const lines = [];
 
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
 					// else and an indent does not.
 					const lang = child.getAttribute('data-lang') ?? '';
 					emit('```' + lang);
-					// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
-					// i filen. Kopiér-knappen ligger inde i 
 for at kunne placeres i
-					// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
-					// ind i hver eneste kodeblok, hver gang noten blev gemt.
-					const code = [...child.childNodes]
-						.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
-						.map((n) => n.textContent ?? '')
-						.join('');
+					const code = codeText(child);
 					for (const l of code.replace(/\n$/, '').split('\n')) {
 						emit(l);
 					}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
 		const pad = '  '.repeat(depth);
 
 		for (const li of el.children) {
+			// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
+			// Browserne er uenige om, hvilken af de to former en indrykning laver, og
+			// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
+			// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
+			if (li.tagName === 'UL' || li.tagName === 'OL') {
+				list(li, depth + 1, emit);
+				continue;
+			}
 			if (li.tagName !== 'LI') continue;
 
 			// Punktets egen tekst er alt undtagen de lister, der ligger under det.
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index 369d108..837cfac 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -14,8 +14,8 @@
 	import { api, humanMessage } from '$lib/api.js';
 	import { app } from '$lib/stores.svelte.js';
 	import { page } from '$app/stores';
-	import { untrack } from 'svelte';
-	import { goto } from '$app/navigation';
+	import { onMount, untrack } from 'svelte';
+	import { afterNavigate, goto } from '$app/navigation';
 	import { t, tag } from '$lib/i18n.svelte.js';
 	import { NOTE, startDrag } from '$lib/dnd.js';
 	import { colorVar } from '$lib/colors.js';
@@ -511,15 +511,43 @@
 		query = inURL;
 	});
 
-	// Arriving from somewhere that names a note — a task's panel, a project's page —
-	// opens it rather than dropping the person on a list to find it again.
-	let asked = $derived($page.url.searchParams.get('note'));
-	$effect(() => {
-		if (!asked || selectedId === asked) return;
-		const found = notes.find((n) => n.id === asked);
+	/**
+	 * En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
+	 * træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
+	 * den igen.
+	 *
+	 * Bundet til selve navigationen og ikke til adressens indhold, og det er hele
+	 * pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
+	 * bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
+	 * uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
+	 * noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
+	 * gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
+	 * sprang tilbage til den første.
+	 *
+	 * En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
+	 * Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
+	 * ved hver ændring.
+	 */
+	function openFromURL(url) {
+		const id = url?.searchParams.get('note');
+		if (!id || id === selectedId) return;
+		const found = notes.find((n) => n.id === id);
 		if (found) open(found);
-		else api.note(asked).then(open).catch(() => {});
-	});
+		else api.note(id).then(open).catch(() => {});
+	}
+
+	// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
+	// den anden.
+	//
+	// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
+	// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
+	// En lytter, der registreres der, hører den aldrig — `/noter?note=` tastet
+	// eller fulgt udefra åbnede så ingenting.
+	//
+	// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
+	// programmet, monteres siden ikke om.
+	onMount(() => openFromURL($page.url));
+	afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
 
 	/**
 	 * Hvilken hentning der gælder.