diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a967fbc..27da7b1 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -5102,6 +5102,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * Blokke, som retur efterlod som
, bliver brødtekst — også bagefter. + * + * Chromium svarer på retur med en bar
. Den læses som brødtekst og er ikke + * formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er + * udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger, + * hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så + * retter den den næste blok to gange og lader denne stå. + * + * Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit + * bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var + * en
og "linje to" og "linje tre" var

. + * + * 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;