fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
Tre ting meldt ind sammen. De to første manglede; den tredje var i stykker. --- man kunne søge og ikke vælge --- Regression fra #84. Siden holdt op med at SKRIVE adressen, men blev ved med at læse den ved hver ændring: let asked = $derived($page.url.searchParams.get('note')); $effect(() => { if (!asked || selectedId === asked) return; … }); Kom man fra ⌘K med `?note=<id1>`, stod `asked` fast på id1 for altid. Hvert klik på en anden række satte `selectedId`, effekten så uenigheden og åbnede id1 igen. Man blev trukket tilbage til den første træffer. En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med. Den læses nu ved ankomsten — og der er TO ankomster, hvilket den første udgave af rettelsen ikke tog højde for: layoutet tegner ingenting, mens kontoen hentes, så siden monteres efter den første navigation er forbi, og en lytter registreret der hører den aldrig. `/noter?note=<id>` tastet eller fulgt udefra åbnede så ingenting. `onMount` og `afterNavigate`, samme funktion, med `id === selectedId` som værn mod at begge fyrer. --- en blok kunne ikke gemme flere linjer --- Shift+retur bliver i blokken, men det browseren lægger ind er et <br>, og `textContent` på et <br> er den tomme streng. Blokken blev skrevet til Markdown som én linje: "first linesecond line". Usynligt indtil en genindlæsning, for på skærmen står linjerne rigtigt hele tiden. `codeText()` læser blokken rekursivt og gør <br> til et linjeskift. Rekursivt, fordi indholdet ligger i et <code> inde i <pre>: en filtrering over <pre>'ens egne børn spørger det ene element om en textContent, der allerede har tabt linjeskiftet. --- lister havde ingen vej til niveau to --- Tab var ikke bundet til noget uden for forslagslisten, så den gjorde det, Tab gør: flyttede fokus ud af noten. Den rykker nu ind og shift+Tab ud, og kun inde i en liste — ellers er noten en boks, man ikke kan tabbe ud af. Og skriveren ville have tabt resultatet: `list()` læste kun en underliste, der ligger INDE i sit punkt. Lægger browseren den som søskende, og de gør det uens, blev grenen sprunget over uden lyd. At binde Tab uden den her halvdel ville have lavet en fejl, hvor indrykkede punkter forsvandt ved gemningen. Begge nye prøver måler EFTER en genindlæsning. Før den ser begge dele rigtige ud på skærmen; det er turen gennem Markdown, der afgør, om det blev skrevet ned. Fuld chromium-suite kørt to gange: 92 bestået, 0 fejlet, begge gange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bb2c9cda6
commit
f34690f41a
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user