From f0d18bb80044ebfda78ea104c9e6ba20e0111ba7 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Mon, 24 Aug 2026 09:27:44 +0200 Subject: [PATCH] =?UTF-8?q?fix(notes):=20let=20a=20monospace=20line=20be?= =?UTF-8?q?=20left=20=E2=80=94=20on=20return=20and=20through=20the=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A
 is preformatted, so the browser answered Enter by inserting a 
and keeping the caret inside it. A monospace last line therefore trapped everything typed after it in monospace, with no way back to body by typing — and the style menu could not get out either: `formatBlock('p')` on a line inside a
 nests a

*inside* the block (`

…

body

`), so the text stayed monospace and the note was left malformed. Reported as "monospace keeps going and I can neither delete it nor switch to body". Two fixes, both in the editor: - Plain Enter in a
 now steps out: the tail after the caret becomes a
  paragraph below the block, and that is where the caret lands. Shift+Enter is
  untouched, so a soft line break still writes multi-line code inside the block —
  Enter to leave, Shift+Enter to stay.

- The style menu converts anything touching a 
 by hand, replacing the element
  rather than reformatting inside it, so a monospace line goes cleanly back to body
  (or to a heading) instead of nesting.

The split can leave an empty text node in the new paragraph, and a block with no
element and no text is not a caret host in Chromium — the next keystroke falls back
into the block above. So an empty paragraph is given a 
to stand in, which was the difference between the caret landing in the new line and the text reappearing in the code. e2e: a monospace line left on Enter is body after it, Shift+Enter stays monospace, the menu returns it to body, and the whole thing survives the round-trip through Markdown and a reload. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 62 +++++++++++++++ web/src/lib/components/NoteEditor.svelte | 98 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index ab2aa9a..ebe855d 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -3049,6 +3049,68 @@ test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) expect(trouble).toEqual([]); }); +test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + await page.getByRole('button', { name: 'Ny note' }).click(); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await ed.click(); + await page.keyboard.type('Titel'); + await page.keyboard.press('Enter'); + + // En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere + // monospace. Før rettelsen holdt en
 på markøren, og alt derefter blev ved
+	// med at være kode.
+	await page.getByRole('button', { name: 'Formatér' }).click();
+	await page.getByRole('menuitem', { name: 'Monotype' }).click();
+	await page.keyboard.type('kode linje');
+	await page.keyboard.press('Enter');
+	await page.keyboard.type('almindelig tekst');
+
+	// Koden står i sin blok; teksten efter står uden for den.
+	await expect(ed.locator('pre')).toHaveText('kode linje');
+	await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
+	await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
+
+	// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
+	await page.getByRole('button', { name: 'Formatér' }).click();
+	await page.getByRole('menuitem', { name: 'Monotype' }).click();
+	await page.keyboard.type('linje et');
+	await page.keyboard.press('Shift+Enter');
+	await page.keyboard.type('linje to');
+	await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
+
+	// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
+	// lagde et 

inde i

 og ikke kom nogen vegne.
+	await page.getByRole('button', { name: 'Formatér' }).click();
+	await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
+	// Den blok er nu brødtekst, ikke kode: den står som 

, ikke i en

.
+	await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
+	await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
+
+	// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
+	// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
+	await page.getByRole('button', { name: 'Ny note' }).click();
+	await ed.click();
+	await page.keyboard.type('Rundtur');
+	await page.keyboard.press('Enter');
+	await page.getByRole('button', { name: 'Formatér' }).click();
+	await page.getByRole('menuitem', { name: 'Monotype' }).click();
+	await page.keyboard.type('docker pull');
+	await page.keyboard.press('Enter');
+	await page.keyboard.type('og så videre i almindelig skrift');
+	await ed.blur();
+	await page.waitForTimeout(1200);
+	await page.reload();
+	await page.getByRole('button', { name: /Rundtur/ }).click();
+	await expect(ed.locator('pre')).toContainText('docker pull');
+	await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
+	await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
+
+	expect(trouble).toEqual([]);
+});
+
 test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
 	const trouble = watchForTrouble(page);
 	await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index f88831f..da68a5a 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -120,11 +120,99 @@
 
 	function setStyle(style) {
 		stylesOpen = false;
+		editor?.focus();
 		if (style.key === 'bullet') return apply('insertUnorderedList');
 		if (style.key === 'numbered') return apply('insertOrderedList');
+
+		// formatBlock is unreliable around 
. Asked to turn a monospace line back
+		// into body, Chromium and WebKit nest a 

*inside* the

 —
+		// `
…

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);
+			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.
+	 */
+	function replaceBlock(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.
+		block.querySelectorAll?.('.copy').forEach((b) => b.remove());
+
+		const el = document.createElement(tag);
+		while (block.firstChild) el.appendChild(block.firstChild);
+		if (!el.firstChild) el.appendChild(document.createElement('br'));
+		block.replaceWith(el);
+
+		const range = document.createRange();
+		range.selectNodeContents(el);
+		range.collapse(false);
+		const selection = window.getSelection();
+		selection.removeAllRanges();
+		selection.addRange(range);
+
+		readState();
+		changed();
+	}
+
+	/**
+	 * Steps out of a code block on Enter.
+	 *
+	 * A 
 is preformatted, so the browser answers Enter by inserting a 
and + * staying inside it — which means a monospace last line makes every line after + * it monospace too, with no way back to body by typing. Plain Enter now leaves + * the block: the tail after the caret becomes a paragraph below the
, and
+	 * that is where the caret lands. Shift+Enter is left untouched, so a soft line
+	 * break still writes multi-line code inside the block.
+	 */
+	function exitPre(pre) {
+		pre.querySelectorAll('.copy').forEach((b) => b.remove());
+
+		const selection = window.getSelection();
+		const range = selection.getRangeAt(0);
+
+		// Everything from the caret to the end of the block leaves with the new line.
+		const tail = document.createRange();
+		tail.setStart(range.endContainer, range.endOffset);
+		tail.setEnd(pre, pre.childNodes.length);
+		const frag = tail.extractContents();
+
+		// A break at the head of what we carried is the empty line return was pressed
+		// on; it should not open the paragraph with a blank row.
+		while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
+
+		const p = document.createElement('p');
+		if (frag.childNodes.length) p.appendChild(frag);
+		// The split can leave an empty text node behind, and a block with no element
+		// and no text is not a caret host in Chromium — the next keystroke falls back
+		// into the block above. An empty paragraph needs a 
to be stood in. + if (!p.textContent && !p.querySelector('br, img')) { + p.replaceChildren(document.createElement('br')); + } + + // And the same break seen from the other side, left dangling in the
.
+		while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
+
+		// Leaving an empty block behind is leaving nothing: the 
 itself goes.
+		if (!pre.textContent.trim()) pre.replaceWith(p);
+		else pre.after(p);
+
+		const caret = document.createRange();
+		caret.setStart(p, 0);
+		caret.collapse(true);
+		selection.removeAllRanges();
+		selection.addRange(caret);
+	}
+
 	/** What the caret is standing in, for the toolbar's pressed states. */
 	function readState() {
 		if (!editor) return;
@@ -470,6 +558,16 @@
 		}
 
 		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
+			// monospace last line traps everything typed after it in monospace.
+			const block = currentBlock();
+			if (block && block.tagName === 'PRE') {
+				event.preventDefault();
+				exitPre(block);
+				changed();
+				return;
+			}
 			// After the split, not before it.
 			setTimeout(() => {
 				keepTitleFirst();