Merge branch 'afsnit-efter-retur' into markoer-i-titlen

This commit is contained in:
kristianwind
2026-10-08 07:55:04 +02:00
2 changed files with 128 additions and 17 deletions
+79
View File
@@ -5102,6 +5102,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. 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 <div> og "linje to" og "linje tre" var <p>.
*
* 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 <div> 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.
*
+49 -17
View File
@@ -864,23 +864,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 <div>, 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 <h1> 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 <div>, 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 <div> mens "linje to" og "linje tre" blev <p>: 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 <div> 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 <div>, 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)
);
}
/**
@@ -955,9 +986,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;