From b497b76be49ddd69ef3e4035d95ba9b8412013d0 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Fri, 2 Oct 2026 22:58:08 +0200 Subject: [PATCH] =?UTF-8?q?fix(noter):=20monotype=20tager=20hele=20markeri?= =?UTF-8?q?ngen,=20og=20farvel=C3=A6gningen=20taber=20ingen=20linjer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til monotype". Det førte til en anden fejl, der var værre. --- den meldte --- Monotype går uden om execCommand, fordi formatBlock pakker et

ind i

'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den 
-fejl, der blev rettet i #91: `textContent` på et
er den tomme streng. Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode: "echo enecho to". Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted, eksporteret og brugt begge steder. Begge prøver er efterprøvet mod den gamle kode. Fuld chromium-suite kørt to gange: 94 bestået, 0 fejlet, begge gange. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 102 +++++++++++++++++++++ web/src/lib/components/NoteEditor.svelte | 107 ++++++++++++++++++----- web/src/lib/richtext.js | 8 +- 3 files changed, 192 insertions(+), 25 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 5932d1d..c892f41 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4992,6 +4992,108 @@ test('en punktliste kan have niveauer, der overlever gemningen', async ({ page } expect(trouble).toEqual([]); }); +/** + * Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt. + * + * Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i + * den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien + * brugte `textContent` — hvor et
er den tomme streng. Så en blok skrevet med + * shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På + * skærmen, ikke kun i filen, og uden at man havde rørt teksten. + * + * Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme + * svar". Der var to, og de var uenige om netop det tegn. + */ +test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + await nyNote(page); + await page.keyboard.type('Farvelagt'); + await page.keyboard.press('Enter'); + await page.getByRole('button', { name: 'Formatér' }).click(); + await page.getByRole('menuitem', { name: 'Monotype' }).click(); + await page.keyboard.type('echo en'); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.type('echo to'); + + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + // Ud af blokken, så farvelægningen tager fat i den. + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('End'); + await ed.getByText('Farvelagt', { exact: true }).click(); + + // Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så + // den skal ud, før teksten læses — præcis det, koden selv gør. + const tekst = await ed.locator('pre').evaluate((el) => { + const kopi = el.cloneNode(true); + kopi.querySelectorAll('.copy').forEach((b) => b.remove()); + return kopi.innerText.trim(); + }); + expect(tekst).toBe('echo en\necho to'); + + expect(trouble).toEqual([]); +}); + +/** + * Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste. + * + * Monotype går uden om `execCommand`, fordi formatBlock pakker et

ind i + *

'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
+ * altså det ene blok markøren står i — så af en markering over ti linjers
+ * terminaludskrift blev den første linje til kode og resten stod tilbage som
+ * brødtekst.
+ *
+ * Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
+ * kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
+ * hegn i filen.
+ */
+test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
+	const trouble = watchForTrouble(page);
+	await page.goto('/noter');
+
+	await nyNote(page);
+	await page.keyboard.type('Udskrift');
+	await page.keyboard.press('Enter');
+	await page.keyboard.type('linje et');
+	await page.keyboard.press('Enter');
+	await page.keyboard.type('linje to');
+	await page.keyboard.press('Enter');
+	await page.keyboard.type('linje tre');
+
+	const ed = page.getByRole('textbox', { name: 'Notens tekst' });
+	await expect(ed.locator('p')).toHaveCount(3);
+
+	// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
+	// af den første til enden af den sidste.
+	await ed.getByText('linje et', { exact: true }).click();
+	await page.keyboard.press('Home');
+	await page.keyboard.press('Shift+ArrowDown');
+	await page.keyboard.press('Shift+ArrowDown');
+	await page.keyboard.press('Shift+End');
+
+	await page.getByRole('button', { name: 'Formatér' }).click();
+	await page.getByRole('menuitem', { name: 'Monotype' }).click();
+
+	// Én blok med alle tre linjer, og ingen brødtekst tilbage.
+	const blok = ed.locator('pre');
+	await expect(blok).toHaveCount(1);
+	await expect(ed.locator('p')).toHaveCount(0);
+
+	const gemt = noteGemt(page);
+	await ed.blur();
+	await gemt;
+	await page.reload();
+	await page.getByRole('button', { name: /Udskrift/ }).click();
+
+	await expect(ed.locator('pre')).toHaveCount(1);
+	expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
+		'linje et\nlinje to\nlinje tre'
+	);
+
+	expect(trouble).toEqual([]);
+});
+
 /**
  * En kodeblok med flere linjer kommer tilbage med dem alle.
  *
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index 8831a5d..47e6c29 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -13,7 +13,7 @@
 	 * unforgivable. When the replacement (the EditContext API) is everywhere, this
 	 * is the file to change.
 	 */
-	import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
+	import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
 	import { highlight, guessLanguage } from '$lib/highlight.js';
 	import { t } from '$lib/i18n.svelte.js';
 	import { app } from '$lib/stores.svelte.js';
@@ -133,20 +133,78 @@
 		// `
…

body

` — so the text stays monospace and there is no way // out of the block from the menu. Do any conversion that touches a
 by
 		// hand, replacing the element rather than reformatting inside it.
-		const block = currentBlock();
-		if (block && (block.tagName === 'PRE' || style.tag === 'pre')) {
-			replaceBlock(block, style.tag);
+		//
+		// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
+		// `currentBlock()`, og det er ét blok — så en markering over ti linjers
+		// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
+		// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
+		const blocks = selectedBlocks();
+		if (!blocks.length) return apply('formatBlock', style.tag);
+
+		if (style.tag === 'pre') {
+			mergeToMono(blocks);
+			return;
+		}
+
+		// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
+		// tekstknuderne flytter med over i de nye elementer, så den range, browseren
+		// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
+		// formatBlock bagefter, nu hvor der ikke er en 
 i vejen.
+		const pres = blocks.filter((b) => b.tagName === 'PRE');
+		for (const b of pres) swapTag(b, style.tag);
+		if (pres.length === blocks.length) {
+			readState();
+			changed();
 			return;
 		}
 		apply('formatBlock', style.tag);
 	}
 
 	/**
-	 * Replaces a block element with one of another tag, keeping its contents and the
-	 * caret. Used where execCommand cannot be trusted — converting to or from a
-	 * 
, which it either nests or ignores.
+	 * Alle de blokke, markeringen rører — ikke kun den, markøren står i.
+	 *
+	 * En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
+	 * som før.
 	 */
-	function replaceBlock(block, tag) {
+	function selectedBlocks() {
+		const selection = window.getSelection();
+		if (!selection || !selection.rangeCount || !editor) return [];
+		const range = selection.getRangeAt(0);
+		return [...editor.children].filter((b) => range.intersectsNode(b));
+	}
+
+	/**
+	 * Lægger flere blokke sammen til én monotypeblok.
+	 *
+	 * Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
+	 * Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
+	 * tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
+	 * de er.
+	 */
+	function mergeToMono(blocks) {
+		const pre = document.createElement('pre');
+		// blockText og ikke textContent: et 
har den tomme streng som + // textContent, og det er netop
, der står for et linjeskift inde i en + // blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje. + pre.textContent = blocks.map((b) => blockText(b)).join('\n'); + if (!pre.textContent) pre.appendChild(document.createElement('br')); + + blocks[0].replaceWith(pre); + for (const b of blocks.slice(1)) b.remove(); + + const range = document.createRange(); + range.selectNodeContents(pre); + range.collapse(false); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + readState(); + changed(); + } + + /** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */ + function swapTag(block, tag) { // The copy button lives inside the
 so it can sit in its corner; it is
 		// not text and must not travel into a paragraph. addCopyButtons puts it back
 		// on the next render for any 
 that still needs one.
@@ -156,6 +214,16 @@
 		while (block.firstChild) el.appendChild(block.firstChild);
 		if (!el.firstChild) el.appendChild(document.createElement('br'));
 		block.replaceWith(el);
+		return el;
+	}
+
+	/**
+	 * Replaces a block element with one of another tag, keeping its contents and the
+	 * caret. Used where execCommand cannot be trusted — converting to or from a
+	 * 
, which it either nests or ignores.
+	 */
+	function replaceBlock(block, tag) {
+		const el = swapTag(block, tag);
 
 		const range = document.createRange();
 		range.selectNodeContents(el);
@@ -380,7 +448,7 @@
 			btn.addEventListener('click', async (e) => {
 				e.preventDefault();
 				e.stopPropagation();
-				const text = codeTextOf(pre);
+				const text = blockText(pre);
 				try {
 					await navigator.clipboard.writeText(text.replace(/\n$/, ''));
 					btn.textContent = t('notes.copied');
@@ -394,19 +462,16 @@
 	}
 
 	/**
-	 * Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
+	 * Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
+	 * den her kommentar påstod i forvejen, mens der stod en kopi lige her.
 	 *
-	 * Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
-	 * skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
-	 * skriver det i filen. Var de to uenige, ville forskellen ende i noten.
+	 * De to skal bruge det samme svar: farvelægningen, som skriver resultatet
+	 * tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
+	 * var uenige om ét tegn — kopien her læste `textContent`, og et 
har den + * tomme streng som textContent. Så en blok, man havde skrevet med shift+retur, + * fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede + * væk fra den. På skærmen, ikke kun i filen. */ - function codeTextOf(pre) { - const from = pre.querySelector('code') ?? pre; - return [...from.childNodes] - .filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy')) - .map((n) => n.textContent ?? '') - .join(''); - } function colourCode() { if (!editor) return; @@ -419,7 +484,7 @@ // maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev // "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen // spiste den kode, den var sat der for at kopiere. - const code = codeTextOf(pre); + const code = blockText(pre); const lang = pre.getAttribute('data-lang') || guessLanguage(code); if (lang) pre.setAttribute('data-lang', lang); const painted = highlight(code, lang); diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js index caf10f2..b39ffac 100644 --- a/web/src/lib/richtext.js +++ b/web/src/lib/richtext.js @@ -315,7 +315,7 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/; const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName)); /** - * Teksten i en kodeblok, med linjeskiftene i behold. + * Teksten i et blok, 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 @@ -332,7 +332,7 @@ const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagNa *
'ens egne børn ser kun det ene element og spørger det om en textContent,
  * der allerede har tabt linjeskiftet.
  */
-function codeText(node) {
+export function blockText(node) {
 	let out = '';
 	for (const n of node.childNodes) {
 		if (n.nodeType !== Node.ELEMENT_NODE) {
@@ -344,7 +344,7 @@ function codeText(node) {
 			out += '\n';
 			continue;
 		}
-		out += codeText(n);
+		out += blockText(n);
 	}
 	return out;
 }
@@ -401,7 +401,7 @@ export function htmlToMarkdown(root) {
 					// else and an indent does not.
 					const lang = child.getAttribute('data-lang') ?? '';
 					emit('```' + lang);
-					const code = codeText(child);
+					const code = blockText(child);
 					for (const l of code.replace(/\n$/, '').split('\n')) {
 						emit(l);
 					}