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:
@@ -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