.
+ *
+ * Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
+ * runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
+ * og holder på invarianten i stedet: ingen blok i en note er browserens egen
+ * beholder, uanset hvor markøren har været i mellemtiden.
+ */
+test('blokke retur efterlod som
bliver brødtekst alligevel', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/noter');
+
+ const ed = await nyNote(page);
+ await page.keyboard.type('Overskrift');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('første afsnit');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('andet afsnit');
+ await expect(ed.locator('p')).toHaveCount(2);
+
+ // Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
+ // så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
+ // der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
+ // når den første ombytning tager dens node ud af dokumentet.
+ await ed.evaluate((el) => {
+ for (const para of [...el.querySelectorAll('p')]) {
+ const div = document.createElement('div');
+ while (para.firstChild) div.appendChild(para.firstChild);
+ para.replaceWith(div);
+ }
+
+ // Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
+ // brugeren skriver videre. At erstatte blokken kollapser markeringen til
+ // begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
+ const range = document.createRange();
+ range.selectNodeContents(el.lastElementChild);
+ range.collapse(false);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+ await expect(ed.locator('p')).toHaveCount(0);
+ await expect(ed.locator('> div')).toHaveCount(2);
+
+ // Næste retur henter dem begge ind igen, sammen med den nye linje.
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('tredje afsnit');
+
+ await expect(ed.locator('> div')).toHaveCount(0);
+ await expect(ed.locator('p')).toHaveCount(3);
+ await expect(ed.locator('h1')).toHaveCount(1);
+
+ // Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
+ // rettelse. Den skal også overleve en gemning.
+ const gemt = noteGemt(page);
+ await ed.blur();
+ await gemt;
+ await page.reload();
+ await page.getByRole('button', { name: /Overskrift/ }).click();
+
+ await expect(ed.locator('> div')).toHaveCount(0);
+ await expect(ed.locator('p')).toHaveCount(3);
+ expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
+ expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
+ expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
+
+ expect(trouble).toEqual([]);
+});
+
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index 47e6c29..0bb96f8 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -821,23 +821,54 @@
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
- function keepTitleFirst() {
- const block = currentBlock();
- if (!block) return;
+ function keepTitleFirst(split) {
+ if (!editor) return;
- // A heading that is not the first line becomes body.
- if (block.tagName === 'H1' && block !== editor.firstElementChild) {
- document.execCommand('formatBlock', false, 'p');
- return;
- }
+ // Der hvor markøren står, gør execCommand det, for det er den, der lægger
+ // ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
+ // ligesom den tager et ord tilbage.
+ const caret = currentBlock();
+ if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
- // And the browser's own container becomes a paragraph. Chromium answers Enter
- // with a bare
, which reads as body but is not styled as one — so the
- // spacing between paragraphs quietly disappears the moment somebody presses
- // return rather than choosing Brødtekst from the menu.
- if (block.tagName === 'DIV') {
- document.execCommand('formatBlock', false, 'p');
- }
+ // Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
+ // browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
+ // blok, delingen lige skabte: en
andre steder i noten kan være en linje,
+ // nogen har valgt Titel til i menuen, og den er deres at beholde.
+ const lavet = split?.nextElementSibling;
+ if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
+
+ // Og hver , rettelsen ikke nåede.
+ //
+ // Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
+ // Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
+ // står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
+ // blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
+ // et" blev en
mens "linje to" og "linje tre" blev
: brødtekst uden
+ // afstand, hos netop de brugere der skriver hurtigst.
+ //
+ // Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
+ // udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
+ // node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
+ // kæden aldrig rettet. En
er altid browserens egen beholder og aldrig
+ // nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
+ const her = currentBlock();
+ for (const block of [...editor.children])
+ if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
+ }
+
+ /**
+ * En blok, der ikke må stå, som den står.
+ *
+ * Browserens egen beholder: Chromium svarer på retur med en bar
, som læses
+ * som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
+ * forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
+ * hver linje er en titel, er en note uden titel.
+ */
+ function stray(block) {
+ if (block.parentNode !== editor) return false;
+ return (
+ block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
+ );
}
/**
@@ -912,9 +943,10 @@
changed();
return;
}
- // After the split, not before it.
+ // After the split, not before it — and with the block the split started from,
+ // because by the time this runs the caret may be two blocks further on.
setTimeout(() => {
- keepTitleFirst();
+ keepTitleFirst(block);
changed();
}, 0);
return;