diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index d330b91..0bf9457 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2312,3 +2312,79 @@ test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => expect(trouble).toEqual([]); }); + +test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + // Formerne, der hver især gik tabt: en punktliste, en nummereret, en der + // begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et + // citat. Den sidste er den, en note fra Apple Noter er fuld af. + const markdown = [ + '# Former', + '', + '- Punkt', + '- Punkt 2', + '', + '1. Nummer', + '2. Nummer 2', + '', + '10. Ti', + '11. Elleve', + '', + '- Ydre', + ' - Indre', + '', + '> Et citat', + '> - med en liste i' + ].join('\n'); + + const id = await page.evaluate(async (body) => { + const r = await fetch('/api/v1/notes', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, + body: JSON.stringify({ body }) + }); + return (await r.json()).id; + }, markdown); + + await page.goto('/noter?note=' + id); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await expect(ed.locator('ul > li').first()).toBeVisible(); + + // Vist som lister, ikke som linjer med bindestreger i. + await expect(ed.locator('ol')).toHaveCount(2); + await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i'); + // Den indlejrede ligger inde i sit punkt, ikke ved siden af. + await expect(ed.locator('ul li ul li')).toHaveText('Indre'); + // Og den, der begyndte ved ti, gør det stadig. + await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti'); + + // Rør noten, sådan som en tastning gør, og lad den gemme. + await ed.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await page.waitForTimeout(1200); + + // Det, der står i filen bagefter, er det, der stod i den før. + // + // Det var her, det gik galt: alt fra det første punkt blev til én linje — + // "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som + // tekst. Ingen havde rørt de linjer. + const body = await page.evaluate(async (noteId) => { + const r = await fetch('/api/v1/notes/' + noteId, { + credentials: 'include', + headers: { 'Sec-Fetch-Site': 'same-origin' } + }); + return (await r.json()).body; + }, id); + + expect(body).toContain('- Punkt\n- Punkt 2'); + expect(body).toContain('1. Nummer\n2. Nummer 2'); + expect(body).toContain('10. Ti\n11. Elleve'); + expect(body).toContain('- Ydre\n - Indre'); + expect(body).toContain('> Et citat\n> - med en liste i'); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index e363cc3..2083c25 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -22,6 +22,8 @@ let { note, onchange, onsave } = $props(); let editor; + // Forslagslisten, så tastaturvalget kan rulle den frem. + let list; let stylesOpen = $state(false); // Which marks are on where the caret is, so the toolbar shows state rather than // only offering actions. @@ -166,9 +168,29 @@ return; } const term = partial.term.toLowerCase(); - suggestions = app.projects - .filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term)) - .slice(0, 6); + + // Alle der passer, ikke de seks første. + // + // Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde + // noget værre end at skjule: med tyve projekter og et blot tastet `#` viste + // den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede + // noget — kun at ens eget projekt ikke var der. + // + // Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med + // det tastede, står øverst, resten alfabetisk. Skriver man "ga", er + // GarageRisteriet den første, ikke den, der tilfældigvis lå først. + const matches = app.projects.filter( + (p) => !p.is_inbox && p.name.toLowerCase().includes(term) + ); + matches.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + const ap = an.startsWith(term); + const bp = bn.startsWith(term); + if (ap !== bp) return ap ? -1 : 1; + return an.localeCompare(bn, 'da'); + }); + suggestions = matches; chosen = 0; } @@ -227,6 +249,19 @@ } } + /** + * Ruller det valgte forslag frem, når listen er længere end sin egen kasse. + * + * Efter opdateringen, ikke før: `chosen` er netop sat, og rækken bærer klassen + * først når Svelte har skrevet den ud. Uden ventetiden ruller den til den + * forrige. + */ + function keepChosenInView() { + requestAnimationFrame(() => { + list?.querySelector('button.on')?.scrollIntoView({ block: 'nearest' }); + }); + } + function onkeydown(event) { // The suggestion list owns the arrows and return while it is open, the way // every other completion does. Escape closes it without choosing. @@ -234,11 +269,13 @@ if (event.key === 'ArrowDown') { event.preventDefault(); chosen = (chosen + 1) % suggestions.length; + keepChosenInView(); return; } if (event.key === 'ArrowUp') { event.preventDefault(); chosen = (chosen - 1 + suggestions.length) % suggestions.length; + keepChosenInView(); return; } if (event.key === 'Enter' || event.key === 'Tab') { @@ -366,7 +403,7 @@ -