Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e687185fb | ||
|
|
8229800767 |
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -41,6 +43,10 @@
|
||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||
loadedId = note.id;
|
||||
active = {};
|
||||
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
|
||||
// er det den rige flade, man skal møde — ellers står den næste note som kode,
|
||||
// uden at nogen har bedt om det.
|
||||
asSource = false;
|
||||
colourCode();
|
||||
});
|
||||
|
||||
@@ -136,6 +142,51 @@
|
||||
readSuggestions();
|
||||
}
|
||||
|
||||
// --- kilden ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Noten som den Markdown, den er.
|
||||
*
|
||||
* Fladen er rig tekst, fordi det er den, folk kan. Men filen under er Markdown,
|
||||
* og der er to ting, den rige flade ikke kan: tage imod Markdown, nogen har
|
||||
* skrevet et andet sted — indsat i den rige flade er `## Overskrift` fem tegn og
|
||||
* ikke en overskrift — og vise, hvad der faktisk bliver gemt. Begge dele er det
|
||||
* samme svar: lad folk se kilden.
|
||||
*
|
||||
* Ikke en tredje tilstand at holde styr på: teksten går gennem de samme to
|
||||
* funktioner som altid, kun den anden vej. Det, man ser i kildefeltet, er ord for
|
||||
* ord det, der ligger i databasen.
|
||||
*/
|
||||
let asSource = $state(false);
|
||||
let source = $state('');
|
||||
|
||||
function toggleSource() {
|
||||
if (!asSource) {
|
||||
source = htmlToMarkdown(editor);
|
||||
asSource = true;
|
||||
return;
|
||||
}
|
||||
asSource = false;
|
||||
onchange?.(source);
|
||||
// Fyldt fra kilden, ikke fra `note.body`.
|
||||
//
|
||||
// Noten i lageret er den sidst gemte, og gemningen venter på sin pause — så
|
||||
// den vej ville kaste det, der lige er skrevet i kildefeltet, væk. `loadedId`
|
||||
// røres ikke: $effect ser stadig en note, den har indlæst, og skriver ikke
|
||||
// hen over det her.
|
||||
queueMicrotask(() => {
|
||||
if (!editor) return;
|
||||
editor.innerHTML = source.trim() ? markdownToHtml(source) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
});
|
||||
}
|
||||
|
||||
/** Kilden skrives direkte igennem: det er filen, man redigerer. */
|
||||
function sourceChanged(event) {
|
||||
source = event.currentTarget.value;
|
||||
onchange?.(source);
|
||||
}
|
||||
|
||||
// --- suggesting a project ------------------------------------------------------
|
||||
|
||||
let suggestions = $state([]);
|
||||
@@ -166,9 +217,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 +298,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 +318,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') {
|
||||
@@ -336,11 +422,37 @@
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
||||
anden måde at se det hele på. -->
|
||||
<button
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
aria-label={t('notes.showSource')}
|
||||
title={t('notes.showSource')}></></button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if asSource}
|
||||
<!-- Et almindeligt tekstfelt, fordi det er almindelig tekst. Ingen
|
||||
contenteditable, ingen execCommand: her er der intet at formatere. -->
|
||||
<textarea
|
||||
class="page source"
|
||||
aria-label={t('notes.source')}
|
||||
spellcheck="false"
|
||||
value={source}
|
||||
oninput={sourceChanged}
|
||||
onblur={() => onsave?.()}
|
||||
></textarea>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="page"
|
||||
class:hidden={asSource}
|
||||
bind:this={editor}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
@@ -366,7 +478,7 @@
|
||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||
one that sits still. -->
|
||||
<ul class="suggestions" role="listbox">
|
||||
<ul class="suggestions" role="listbox" bind:this={list}>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
@@ -515,6 +627,10 @@
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||
/* Nu hvor alle der passer står på listen, skal den kunne rulle. Otte rækker
|
||||
er nok til at det ses, at der er flere, uden at menuen dækker noten. */
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
@@ -562,6 +678,41 @@
|
||||
outline: none;
|
||||
line-height: 1.55;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Linjelængden har en grænse, som skærmen ikke har.
|
||||
*
|
||||
* En note i fuld bredde på en stor skærm er linjer på hundrede og fyrre tegn,
|
||||
* og øjet mister hvilken linje det var på, når det springer tilbage. Alle,
|
||||
* der laver noget, man læser i, sætter det her — Apple Noter, Bear, Notion —
|
||||
* og de sætter det omkring det samme sted.
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
|
||||
liste er indhold her og ikke pynt: den skal kunne tælles. */
|
||||
.source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
@@ -590,10 +741,23 @@
|
||||
margin: 0 0 var(--s2);
|
||||
}
|
||||
|
||||
/* Renden skal kunne rumme det bredeste mærke.
|
||||
*
|
||||
* Mærket sættes uden for tekstkanten, inde i listens egen indrykning, og en fast
|
||||
* indrykning på 16px rakte til et punkttegn og ikke til "10." — resten hang ud
|
||||
* over kanten, hvor `.page`, som ruller lodret og derfor beskærer vandret,
|
||||
* klippede det af. Man så et punktum uden sit tal.
|
||||
*
|
||||
* Samme rende til begge slags, frem for en bredere til den nummererede: mærket
|
||||
* lægger sig op ad teksten uanset hvor bred renden er, så deler de den, flugter
|
||||
* punkter og tal — og gør de ikke, står to lister under hinanden med hver sin
|
||||
* venstrekant.
|
||||
*
|
||||
* Målt i em, så den følger skriftstørrelsen, og bred nok til tre cifre. */
|
||||
.page :global(ul),
|
||||
.page :global(ol) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s4);
|
||||
padding-left: 2.2em;
|
||||
}
|
||||
|
||||
.page :global(li) {
|
||||
|
||||
@@ -131,7 +131,21 @@
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
{ key: 'inbox', label: 'nav.inbox' },
|
||||
// Noter hører til her, og bærer et ark frem for en prik.
|
||||
//
|
||||
// Den stod før for sig selv over en streg, hvilket sagde det rigtige og
|
||||
// kostede halvfjerds pixels: en streg, luft over og luft under, og
|
||||
// projekterne skubbet så langt ned, at de to første var alt, man så uden
|
||||
// at rulle. Grunden til at skille den ud var, at den ellers læste som
|
||||
// endnu et filter — men det, der gør den til et filter for øjet, er
|
||||
// prikken, ikke pladsen. Et andet mærke siger "en anden slags ting" og
|
||||
// koster ingenting.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -463,7 +477,20 @@
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{#if item.key === 'notes'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M4 1.5h5.2L12.5 5v9.5H4z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
@@ -471,17 +498,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
They are not a fourth way of looking at the same tasks, which is what
|
||||
everything above is; they are the other half of the program. Sitting in that
|
||||
run made them read as one more filter. -->
|
||||
<div class="apart">
|
||||
<a href="/noter" class:active={current === '/noter'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('notes.title')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- One row for every project the sidebar shows — your own, the Inbox and the
|
||||
shared ones — because all three are somewhere a task can be dropped, and
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
@@ -1025,15 +1041,20 @@
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: var(--s3) 0;
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line);
|
||||
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
|
||||
— mærket skifter, kolonnen gør ikke. */
|
||||
.mark {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
|
||||
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
|
||||
margin: 0 -3.5px;
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
a.active .mark {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.group-head {
|
||||
|
||||
@@ -26,6 +26,8 @@ export const da = {
|
||||
'notes.italic': 'Kursiv',
|
||||
'notes.underline': 'Understreget',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
'notes.styleSubheading': 'Underrubrik',
|
||||
|
||||
@@ -22,6 +22,8 @@ export const en = {
|
||||
'notes.italic': 'Italic',
|
||||
'notes.underline': 'Underline',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
'notes.styleSubheading': 'Subheading',
|
||||
|
||||
+146
-29
@@ -58,17 +58,40 @@ function inlineToHtml(text) {
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = (markdown ?? '').split('\n');
|
||||
const out = [];
|
||||
let list = null;
|
||||
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||
|
||||
const closeList = () => {
|
||||
if (list) {
|
||||
out.push(`</${list}>`);
|
||||
list = null;
|
||||
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
|
||||
//
|
||||
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
|
||||
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
|
||||
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
|
||||
// var væk efter én gemning.
|
||||
//
|
||||
// Underlisten lægges *inde i* sit `<li>`, ikke ved siden af. Browsere viser
|
||||
// begge dele ens, men kun den ene er en liste i en liste, når den læses tilbage
|
||||
// — og det er læsningen tilbage, der skriver filen.
|
||||
const stack = [];
|
||||
let liOpen = false;
|
||||
|
||||
const closeItem = () => {
|
||||
if (liOpen) {
|
||||
out.push('</li>');
|
||||
liOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const closeList = (toDepth = 0) => {
|
||||
while (stack.length > toDepth) {
|
||||
closeItem();
|
||||
out.push(`</${stack.pop()}>`);
|
||||
// Efter et niveau lukkes, står vi igen inde i ophavets `<li>`.
|
||||
liOpen = stack.length > 0;
|
||||
}
|
||||
if (stack.length === 0) liOpen = false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der
|
||||
// begynder med - inde i et shell-script er ikke et punkt i en liste.
|
||||
const fenced = /^```(\w*)\s*$/.exec(line);
|
||||
@@ -88,21 +111,55 @@ export function markdownToHtml(markdown) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
|
||||
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
|
||||
|
||||
if (bullet || numbered) {
|
||||
const want = bullet ? 'ul' : 'ol';
|
||||
if (list !== want) {
|
||||
closeList();
|
||||
out.push(`<${want}>`);
|
||||
list = want;
|
||||
if (item) {
|
||||
const indent = item[1].replace(/\t/g, ' ').length;
|
||||
const depth = Math.floor(indent / 2);
|
||||
const want = item[2] ? 'ul' : 'ol';
|
||||
|
||||
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
|
||||
// `</ul>` lander inde i det `<li>`, det hører til.
|
||||
closeList(depth + 1);
|
||||
|
||||
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
|
||||
// nummereret er to lister, ikke én med blandede mærker.
|
||||
if (stack.length === depth + 1 && stack[depth] !== want) {
|
||||
closeList(depth);
|
||||
}
|
||||
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
|
||||
|
||||
while (stack.length < depth + 1) {
|
||||
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
|
||||
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
|
||||
// gør en gemning uden ændringer noten om.
|
||||
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
|
||||
out.push(`<${want}${want === 'ol' ? start : ''}>`);
|
||||
stack.push(want);
|
||||
liOpen = false;
|
||||
}
|
||||
|
||||
closeItem();
|
||||
out.push(`<li>${inlineToHtml(item[4])}`);
|
||||
liOpen = true;
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
|
||||
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
|
||||
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
|
||||
// det blev en liste i et citat til seks citater med et bindestreg i hver.
|
||||
if (/^>(?: |$)/.test(line)) {
|
||||
const inner = [];
|
||||
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^> ?/, ''));
|
||||
i++;
|
||||
}
|
||||
i--;
|
||||
out.push(`<blockquote>${markdownToHtml(inner.join('\n'))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = BLOCKS.find((b) =>
|
||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||
);
|
||||
@@ -167,13 +224,25 @@ function inlineToMarkdown(node) {
|
||||
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||
* over that is a guess.
|
||||
*/
|
||||
/** The tags that are a block of their own, and must never be read as inline text. */
|
||||
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));
|
||||
|
||||
export function htmlToMarkdown(root) {
|
||||
const lines = [];
|
||||
|
||||
const walk = (node) => {
|
||||
/**
|
||||
* @param node hvad der læses
|
||||
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
|
||||
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
|
||||
* indeholde afsnit og lister uden at de bliver til én linje.
|
||||
*/
|
||||
const walk = (node, emit) => {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (child.textContent.trim()) lines.push(child.textContent);
|
||||
if (child.textContent.trim()) emit(child.textContent);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
@@ -181,20 +250,29 @@ export function htmlToMarkdown(root) {
|
||||
switch (child.tagName) {
|
||||
case 'UL':
|
||||
case 'OL': {
|
||||
let n = 1;
|
||||
for (const li of child.children) {
|
||||
const text = inlineToMarkdown(li).trim();
|
||||
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
||||
list(child, 0, emit);
|
||||
break;
|
||||
}
|
||||
|
||||
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
|
||||
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
|
||||
// — det var sådan en note kom tilbage som én lang linje efter en
|
||||
// gemning, uden at nogen havde rørt teksten.
|
||||
case 'BLOCKQUOTE': {
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
|
||||
} else {
|
||||
emit('> ' + inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DIV':
|
||||
// The browser's own container when nothing else applies. Read through
|
||||
// it rather than treating it as a block, or every paste adds a level.
|
||||
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
|
||||
walk(child);
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
} else {
|
||||
lines.push(inlineToMarkdown(child));
|
||||
emit(inlineToMarkdown(child));
|
||||
}
|
||||
break;
|
||||
case 'PRE': {
|
||||
@@ -203,23 +281,62 @@ export function htmlToMarkdown(root) {
|
||||
// out any more, because a fence survives being pasted somewhere
|
||||
// else and an indent does not.
|
||||
const lang = child.getAttribute('data-lang') ?? '';
|
||||
lines.push('```' + lang);
|
||||
emit('```' + lang);
|
||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||
lines.push(l);
|
||||
emit(l);
|
||||
}
|
||||
lines.push('```');
|
||||
emit('```');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
|
||||
// citatet: læst som én linje forsvandt hvert punktmærke.
|
||||
if (holdsBlocks(child)) {
|
||||
walk(child, emit);
|
||||
break;
|
||||
}
|
||||
const text = inlineToMarkdown(child);
|
||||
lines.push(block ? block.prefix + text : text);
|
||||
emit(block ? block.prefix + text : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
/**
|
||||
* Én liste, og de lister der ligger i den.
|
||||
*
|
||||
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
|
||||
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
|
||||
* fejl, fordi den sker uden at nogen har rørt noget.
|
||||
*/
|
||||
const list = (el, depth, emit) => {
|
||||
let n = Number(el.getAttribute('start') || 1);
|
||||
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||
const pad = ' '.repeat(depth);
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== 'LI') continue;
|
||||
|
||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
|
||||
let text;
|
||||
if (nested.length) {
|
||||
const clone = li.cloneNode(true);
|
||||
for (const sub of [...clone.children]) {
|
||||
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
|
||||
}
|
||||
text = inlineToMarkdown(clone).trim();
|
||||
} else {
|
||||
text = inlineToMarkdown(li).trim();
|
||||
}
|
||||
|
||||
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
|
||||
for (const sub of nested) list(sub, depth + 1, emit);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, (l) => lines.push(l));
|
||||
// Trailing blank lines are what an editor accumulates from people pressing
|
||||
// return at the end; they are not content and they grow every time.
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
|
||||
@@ -348,7 +348,9 @@
|
||||
/* Air on all sides. The heading, the search box and the list all sat flush
|
||||
against the sidebar's rule, which reads as a rendering fault rather than
|
||||
as a layout. */
|
||||
padding: var(--s3) 0 0 var(--s4);
|
||||
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
|
||||
ingen kant havde og ingenting kunne støde imod. */
|
||||
padding: var(--s3) var(--s4) var(--s3) var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: var(--s4);
|
||||
@@ -515,11 +517,30 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Noten er et ark, ikke en flade i appen.
|
||||
*
|
||||
* Apple Notes skriver på hvidt, og det er ikke pynt: et ark, der holder op et
|
||||
* sted, fortæller hvor teksten hører til, og skiller det, man skriver, fra det
|
||||
* program, man skriver det i. Uden det flød noten ud i siden og lignede endnu
|
||||
* en rude.
|
||||
*
|
||||
* `--surface` frem for `#fff`, fordi der er fem temaer. Rollen er "arket løftet
|
||||
* fra grunden" — hvid i lyst, cremet i paper, en anelse lysere end grunden i de
|
||||
* mørke. Et hårdkodet hvidt ville være et blændende ark i mørkt tema. */
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
/* Luft nok til at teksten ikke rører kanten.
|
||||
*
|
||||
* Vandret vokser den med ruden: på en bred skærm skal et ark have marginer,
|
||||
* ikke bare en kant — clamp holder den mellem en rimelig mindste og en, der
|
||||
* ikke bliver til et vindue med tekst i midten. */
|
||||
padding: var(--s4) clamp(var(--s4), 3.5%, var(--s6));
|
||||
}
|
||||
|
||||
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||
|
||||
Reference in New Issue
Block a user