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();