Merge pull request #91 from kristianwind/noter-lister-blokke-og-soegning

fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
This commit is contained in:
Kristian Wind
2026-09-22 14:10:13 +02:00
committed by GitHub
4 changed files with 219 additions and 18 deletions
+117
View File
@@ -4797,6 +4797,24 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
// tilbage til den første. Man kunne søge og ikke vælge.
await list.getByText('Fyrreskov syd').click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
// kunne så ikke komme tilbage til den note, man kom fra.
await page.getByRole('button', { name: /Søg/ }).click();
await palette.getByRole('textbox').fill('Fyrreskov nord');
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
@@ -4923,6 +4941,105 @@ test('klokkens panel bliver inden for skærmen på en telefon', async ({ page })
expect(trouble).toEqual([]);
});
/**
* En punktliste kan have niveauer, og de overlever gemningen.
*
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
*
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
*
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
*/
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Indkøb');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
await page.keyboard.type('Frugt');
await page.keyboard.press('Enter');
await page.keyboard.type('Aebler');
await page.keyboard.press('Tab');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Niveauet er der med det samme: en liste inde i en liste.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Indkøb/ }).click();
// Og efter turen gennem Markdown står det der stadig.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
await ed.locator('ul ul li').click();
await page.keyboard.press('Shift+Tab');
await expect(ed.locator('ul ul li')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
+20
View File
@@ -788,6 +788,17 @@
});
}
/**
* Står markøren i en punktliste eller en nummereret liste?
*
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
* ikke blive uenige om, hvad der er en liste.
*/
const inList = () =>
document.queryCommandState('insertUnorderedList') ||
document.queryCommandState('insertOrderedList');
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.
@@ -816,6 +827,15 @@
}
}
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
if (event.key === 'Tab' && inList()) {
event.preventDefault();
apply(event.shiftKey ? 'outdent' : 'indent');
return;
}
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
+44 -8
View File
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i en kodeblok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
* tiden.
*
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
function codeText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += codeText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
// ind i hver eneste kodeblok, hver gang noten blev gemt.
const code = [...child.childNodes]
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
.map((n) => n.textContent ?? '')
.join('');
const code = codeText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
+38 -10
View File
@@ -14,8 +14,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { afterNavigate, goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -511,15 +511,43 @@
query = inURL;
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
$effect(() => {
if (!asked || selectedId === asked) return;
const found = notes.find((n) => n.id === asked);
/**
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
* den igen.
*
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
* sprang tilbage til den første.
*
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
* ved hver ændring.
*/
function openFromURL(url) {
const id = url?.searchParams.get('note');
if (!id || id === selectedId) return;
const found = notes.find((n) => n.id === id);
if (found) open(found);
else api.note(asked).then(open).catch(() => {});
});
else api.note(id).then(open).catch(() => {});
}
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
// den anden.
//
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
// eller fulgt udefra åbnede så ingenting.
//
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
// programmet, monteres siden ikke om.
onMount(() => openFromURL($page.url));
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
/**
* Hvilken hentning der gælder.