, ikke på : kopiér-knappen ligger inde i blokken for at
+ // kunne sidde i hjørnet af den, så `textContent` på 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.
*
diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte
index e024c20..8831a5d 100644
--- a/web/src/lib/components/NoteEditor.svelte
+++ b/web/src/lib/components/NoteEditor.svelte
@@ -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
diff --git a/web/src/lib/richtext.js b/web/src/lib/richtext.js
index dba6677..caf10f2 100644
--- a/web/src/lib/richtext.js
+++ b/web/src/lib/richtext.js
@@ -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
har den tomme
+ * streng som textContent, og
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 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 inde i blokken: en filtrering over
+ * '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 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.
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index 369d108..837cfac 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -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=` 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.