Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffbd9533b | ||
|
|
f6c6bdccbb | ||
|
|
9e687185fb | ||
|
|
8229800767 |
@@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
// Almost everything is served as a download rather than rendered. An uploaded
|
||||||
// HTML file displayed inline would run its own script on this origin, with the
|
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||||
// session cookie attached — attachments are the one place a user supplies
|
// with the session cookie attached — attachments are the one place a user
|
||||||
// content that another user opens.
|
// supplies content that another user opens.
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
//
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
// The exception is a short list of raster images, and it is not a softening of
|
||||||
|
// that rule but what makes a note work: a note carries its pictures in the text
|
||||||
|
// as ``, and served as octet-stream every one of them is a broken image.
|
||||||
|
// The whole Apple Notes import exists to bring those pictures along.
|
||||||
|
//
|
||||||
|
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||||
|
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||||
|
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||||
|
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||||
|
// type that can run script.
|
||||||
|
disposition := "attachment"
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if inlineImage[a.MimeType] {
|
||||||
|
disposition = "inline"
|
||||||
|
contentType = a.MimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||||
@@ -408,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
|||||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inlineImage is what may be shown rather than downloaded.
|
||||||
|
//
|
||||||
|
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||||
|
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||||
|
// can run script, and a list that grows by accident is how that happens.
|
||||||
|
var inlineImage = map[string]bool{
|
||||||
|
"image/png": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
"image/avif": true,
|
||||||
|
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||||
|
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||||
|
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||||
|
"image/heic": true,
|
||||||
|
"image/heif": true,
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentID := chi.URLParam(r, "attachmentID")
|
attachmentID := chi.URLParam(r, "attachmentID")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -421,10 +422,20 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := path.Base(f.Name)
|
base := path.Base(f.Name)
|
||||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
// The three ways a Markdown file names a neighbouring image: by its path
|
||||||
// was written with, and by its bare name.
|
// inside the archive, by the path relative to the note, and by its bare name.
|
||||||
|
//
|
||||||
|
// The archive path was missing, and it is the one an Apple Notes export
|
||||||
|
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||||
|
// root beside the folder. Only the bare name matched, so only the bare name
|
||||||
|
// was replaced, and every picture in every imported note ended up pointing at
|
||||||
|
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||||
|
// stored and attached correctly; it was the link that was wrong.
|
||||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
refs := []string{f.Name, relative, base}
|
||||||
|
if !strings.Contains(body, f.Name) &&
|
||||||
|
!strings.Contains(body, relative) &&
|
||||||
|
!strings.Contains(body, base) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,9 +452,15 @@ func (s *Server) attachToNote(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||||
|
// stående foran adressen, og linket peger på ingenting.
|
||||||
url := "/api/v1/attachments/" + a.ID
|
url := "/api/v1/attachments/" + a.ID
|
||||||
body = strings.ReplaceAll(body, relative, url)
|
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||||
body = strings.ReplaceAll(body, base, url)
|
for _, ref := range refs {
|
||||||
|
if ref != "" {
|
||||||
|
body = strings.ReplaceAll(body, ref, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
attached++
|
attached++
|
||||||
}
|
}
|
||||||
return body, attached
|
return body, attached
|
||||||
|
|||||||
@@ -255,11 +255,36 @@ def main() -> int:
|
|||||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
||||||
# starttag tømmer linjen før den — så første linje er tom, og
|
# starttag tømmer linjen før den — så første linje er tom, og
|
||||||
# sammenligningen så på ingenting.
|
# sammenligningen så på ingenting.
|
||||||
|
# Titlen kan være delt over flere overskrifter.
|
||||||
|
#
|
||||||
|
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||||
|
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||||
|
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||||
|
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||||
|
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||||
|
#
|
||||||
|
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||||
|
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
while lines and not lines[0].strip():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
|
||||||
lines = lines[1:]
|
taken, joined = 0, ""
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
taken += 1
|
||||||
|
continue
|
||||||
|
if not line.lstrip().startswith("#"):
|
||||||
|
break
|
||||||
|
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||||
|
if not title.strip().startswith(candidate):
|
||||||
|
break
|
||||||
|
joined, taken = candidate, taken + 1
|
||||||
|
if joined == title.strip():
|
||||||
|
break
|
||||||
|
|
||||||
|
if joined == title.strip():
|
||||||
|
lines = lines[taken:]
|
||||||
while lines and not lines[0].strip():
|
while lines and not lines[0].strip():
|
||||||
lines.pop(0)
|
lines.pop(0)
|
||||||
text = "\n".join(lines)
|
text = "\n".join(lines)
|
||||||
|
|||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||||
|
|
||||||
|
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||||
|
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||||
|
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||||
|
|
||||||
|
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||||
|
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||||
|
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||||
|
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||||
|
portion, ville pege på ingenting.
|
||||||
|
|
||||||
|
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||||
|
# portion mere end en, der ryger på målstregen.
|
||||||
|
DEFAULT_LIMIT_MB = 40
|
||||||
|
|
||||||
|
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||||
|
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||||
|
out_dir = src.rstrip("/") + "-portioner"
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||||
|
if not notes:
|
||||||
|
print(f"Ingen .md-filer i {src}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||||
|
weighed = []
|
||||||
|
for name in notes:
|
||||||
|
path = os.path.join(src, name)
|
||||||
|
text = open(path, encoding="utf-8", errors="replace").read()
|
||||||
|
files = []
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
for rel in LINK.findall(text):
|
||||||
|
f = os.path.join(src, rel)
|
||||||
|
if os.path.exists(f):
|
||||||
|
files.append(rel)
|
||||||
|
size += os.path.getsize(f)
|
||||||
|
weighed.append((name, files, size))
|
||||||
|
|
||||||
|
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||||
|
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||||
|
# kan sendes.
|
||||||
|
batches, current, current_size = [], [], 0
|
||||||
|
for item in weighed:
|
||||||
|
if current and current_size + item[2] > limit:
|
||||||
|
batches.append(current)
|
||||||
|
current, current_size = [], 0
|
||||||
|
current.append(item)
|
||||||
|
current_size += item[2]
|
||||||
|
if current:
|
||||||
|
batches.append(current)
|
||||||
|
|
||||||
|
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||||
|
for i, batch in enumerate(batches, start=1):
|
||||||
|
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||||
|
seen = set()
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for name, files, _ in batch:
|
||||||
|
z.write(os.path.join(src, name), name)
|
||||||
|
for rel in files:
|
||||||
|
if rel in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rel)
|
||||||
|
z.write(os.path.join(src, rel), rel)
|
||||||
|
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||||
|
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||||
|
|
||||||
|
print(f"\nLigger i:\n {out_dir}")
|
||||||
|
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -2312,3 +2312,79 @@ test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) =>
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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 { note, onchange, onsave } = $props();
|
||||||
|
|
||||||
let editor;
|
let editor;
|
||||||
|
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||||
|
let list;
|
||||||
let stylesOpen = $state(false);
|
let stylesOpen = $state(false);
|
||||||
// Which marks are on where the caret is, so the toolbar shows state rather than
|
// Which marks are on where the caret is, so the toolbar shows state rather than
|
||||||
// only offering actions.
|
// only offering actions.
|
||||||
@@ -41,6 +43,10 @@
|
|||||||
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||||
loadedId = note.id;
|
loadedId = note.id;
|
||||||
active = {};
|
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();
|
colourCode();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,10 +142,58 @@
|
|||||||
readSuggestions();
|
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 ------------------------------------------------------
|
// --- suggesting a project ------------------------------------------------------
|
||||||
|
|
||||||
let suggestions = $state([]);
|
let suggestions = $state([]);
|
||||||
let chosen = $state(0);
|
let chosen = $state(0);
|
||||||
|
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||||
|
let menuAt = $state(null);
|
||||||
|
let wrap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The word being typed after a #, if that is what is happening.
|
* The word being typed after a #, if that is what is happening.
|
||||||
@@ -159,6 +213,33 @@
|
|||||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor `#` står, målt i wrappens koordinater.
|
||||||
|
*
|
||||||
|
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||||
|
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||||
|
*
|
||||||
|
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||||
|
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||||
|
*/
|
||||||
|
function tagPosition(partial) {
|
||||||
|
if (!wrap) return null;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
const at = range.getBoundingClientRect();
|
||||||
|
const box = wrap.getBoundingClientRect();
|
||||||
|
if (!at.width && !at.height) return null;
|
||||||
|
|
||||||
|
const MENU = { w: 210, h: 240 };
|
||||||
|
const below = at.bottom - box.top + 6;
|
||||||
|
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||||
|
return {
|
||||||
|
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||||
|
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readSuggestions() {
|
function readSuggestions() {
|
||||||
const partial = partialTag();
|
const partial = partialTag();
|
||||||
if (!partial) {
|
if (!partial) {
|
||||||
@@ -166,9 +247,35 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const term = partial.term.toLowerCase();
|
const term = partial.term.toLowerCase();
|
||||||
suggestions = app.projects
|
|
||||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||||
.slice(0, 6);
|
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||||
|
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||||
|
// pixels for hvert bogstav, mens listen snævres ind.
|
||||||
|
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||||
|
|
||||||
|
// 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;
|
chosen = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +334,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) {
|
function onkeydown(event) {
|
||||||
// The suggestion list owns the arrows and return while it is open, the way
|
// The suggestion list owns the arrows and return while it is open, the way
|
||||||
// every other completion does. Escape closes it without choosing.
|
// every other completion does. Escape closes it without choosing.
|
||||||
@@ -234,11 +354,13 @@
|
|||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
chosen = (chosen + 1) % suggestions.length;
|
chosen = (chosen + 1) % suggestions.length;
|
||||||
|
keepChosenInView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||||
|
keepChosenInView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
@@ -285,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap" bind:this={wrap}>
|
||||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||||
collapses the selection first, so the format lands on nothing — the button
|
collapses the selection first, so the format lands on nothing — the button
|
||||||
appears to do nothing at all, which is how it read the first time. -->
|
appears to do nothing at all, which is how it read the first time. -->
|
||||||
@@ -336,11 +458,37 @@
|
|||||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||||
<s>S</s>
|
<s>S</s>
|
||||||
</button>
|
</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>
|
</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 -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="page"
|
class="page"
|
||||||
|
class:hidden={asSource}
|
||||||
bind:this={editor}
|
bind:this={editor}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
role="textbox"
|
role="textbox"
|
||||||
@@ -363,10 +511,15 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
{#if suggestions.length}
|
{#if suggestions.length}
|
||||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
står stille, mens listen snævres ind. -->
|
||||||
one that sits still. -->
|
<ul
|
||||||
<ul class="suggestions" role="listbox">
|
class="suggestions"
|
||||||
|
role="listbox"
|
||||||
|
bind:this={list}
|
||||||
|
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||||
|
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||||
|
>
|
||||||
{#each suggestions as project, i (project.id)}
|
{#each suggestions as project, i (project.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -515,6 +668,10 @@
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
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 {
|
.suggestions button {
|
||||||
@@ -562,6 +719,41 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
overflow-y: auto;
|
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":
|
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||||
@@ -590,10 +782,23 @@
|
|||||||
margin: 0 0 var(--s2);
|
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(ul),
|
||||||
.page :global(ol) {
|
.page :global(ol) {
|
||||||
margin: 0 0 var(--s2);
|
margin: 0 0 var(--s2);
|
||||||
padding-left: var(--s4);
|
padding-left: 2.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page :global(li) {
|
.page :global(li) {
|
||||||
|
|||||||
@@ -131,7 +131,21 @@
|
|||||||
: null,
|
: null,
|
||||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
// 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.
|
// 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)
|
].filter(Boolean)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -463,7 +477,20 @@
|
|||||||
ondragleave={() => (overToday = false)}
|
ondragleave={() => (overToday = false)}
|
||||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
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)}
|
{t(item.label)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,17 +498,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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
|
<!-- 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
|
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.
|
three copies of that would be three chances to fix a bug in two of them.
|
||||||
@@ -1025,15 +1041,20 @@
|
|||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
/* Arket. Samme plads som prikken, så teksten står på linje med resten af runden
|
||||||
half of the program, not a fifth way of looking at the same tasks. */
|
— mærket skifter, kolonnen gør ikke. */
|
||||||
.apart {
|
.mark {
|
||||||
display: flex;
|
width: 13px;
|
||||||
flex-direction: column;
|
height: 13px;
|
||||||
gap: 1px;
|
/* Prikken er 6px bred, og et 13px mærke ville skubbe etiketten tre pixels ud
|
||||||
margin: var(--s3) 0;
|
af flugt med de andre. Negativ margen på hver side lægger det tilbage. */
|
||||||
padding-top: var(--s3);
|
margin: 0 -3.5px;
|
||||||
border-top: 1px solid var(--line);
|
color: var(--ink-faint);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.active .mark {
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-head {
|
.group-head {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const da = {
|
|||||||
'notes.italic': 'Kursiv',
|
'notes.italic': 'Kursiv',
|
||||||
'notes.underline': 'Understreget',
|
'notes.underline': 'Understreget',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
|
'notes.showSource': 'Vis som kode',
|
||||||
|
'notes.source': 'Notens kildetekst',
|
||||||
'notes.styleTitle': 'Titel',
|
'notes.styleTitle': 'Titel',
|
||||||
'notes.styleHeading': 'Overskrift',
|
'notes.styleHeading': 'Overskrift',
|
||||||
'notes.styleSubheading': 'Underrubrik',
|
'notes.styleSubheading': 'Underrubrik',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const en = {
|
|||||||
'notes.italic': 'Italic',
|
'notes.italic': 'Italic',
|
||||||
'notes.underline': 'Underline',
|
'notes.underline': 'Underline',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
|
'notes.showSource': 'Show as code',
|
||||||
|
'notes.source': 'Note source',
|
||||||
'notes.styleTitle': 'Title',
|
'notes.styleTitle': 'Title',
|
||||||
'notes.styleHeading': 'Heading',
|
'notes.styleHeading': 'Heading',
|
||||||
'notes.styleSubheading': 'Subheading',
|
'notes.styleSubheading': 'Subheading',
|
||||||
|
|||||||
+146
-29
@@ -58,17 +58,40 @@ function inlineToHtml(text) {
|
|||||||
export function markdownToHtml(markdown) {
|
export function markdownToHtml(markdown) {
|
||||||
const lines = (markdown ?? '').split('\n');
|
const lines = (markdown ?? '').split('\n');
|
||||||
const out = [];
|
const out = [];
|
||||||
let list = null;
|
|
||||||
let fence = null; // sproget, mens vi er inde i en ```-blok
|
let fence = null; // sproget, mens vi er inde i en ```-blok
|
||||||
|
|
||||||
const closeList = () => {
|
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
|
||||||
if (list) {
|
//
|
||||||
out.push(`</${list}>`);
|
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
|
||||||
list = null;
|
// 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
|
// 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.
|
// begynder med - inde i et shell-script er ikke et punkt i en liste.
|
||||||
const fenced = /^```(\w*)\s*$/.exec(line);
|
const fenced = /^```(\w*)\s*$/.exec(line);
|
||||||
@@ -88,21 +111,55 @@ export function markdownToHtml(markdown) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
|
||||||
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
|
||||||
|
|
||||||
if (bullet || numbered) {
|
if (item) {
|
||||||
const want = bullet ? 'ul' : 'ol';
|
const indent = item[1].replace(/\t/g, ' ').length;
|
||||||
if (list !== want) {
|
const depth = Math.floor(indent / 2);
|
||||||
closeList();
|
const want = item[2] ? 'ul' : 'ol';
|
||||||
out.push(`<${want}>`);
|
|
||||||
list = want;
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
closeList();
|
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) =>
|
const block = BLOCKS.find((b) =>
|
||||||
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
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
|
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||||
* over that is a guess.
|
* 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) {
|
export function htmlToMarkdown(root) {
|
||||||
const lines = [];
|
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) {
|
for (const child of node.childNodes) {
|
||||||
if (child.nodeType === Node.TEXT_NODE) {
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
if (child.textContent.trim()) lines.push(child.textContent);
|
if (child.textContent.trim()) emit(child.textContent);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
@@ -181,20 +250,29 @@ export function htmlToMarkdown(root) {
|
|||||||
switch (child.tagName) {
|
switch (child.tagName) {
|
||||||
case 'UL':
|
case 'UL':
|
||||||
case 'OL': {
|
case 'OL': {
|
||||||
let n = 1;
|
list(child, 0, emit);
|
||||||
for (const li of child.children) {
|
break;
|
||||||
const text = inlineToMarkdown(li).trim();
|
}
|
||||||
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
|
||||||
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
case 'DIV':
|
case 'DIV':
|
||||||
// The browser's own container when nothing else applies. Read through
|
// 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.
|
// 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))) {
|
if (holdsBlocks(child)) {
|
||||||
walk(child);
|
walk(child, emit);
|
||||||
} else {
|
} else {
|
||||||
lines.push(inlineToMarkdown(child));
|
emit(inlineToMarkdown(child));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'PRE': {
|
case 'PRE': {
|
||||||
@@ -203,23 +281,62 @@ export function htmlToMarkdown(root) {
|
|||||||
// out any more, because a fence survives being pasted somewhere
|
// out any more, because a fence survives being pasted somewhere
|
||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
lines.push('```' + lang);
|
emit('```' + lang);
|
||||||
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) {
|
||||||
lines.push(l);
|
emit(l);
|
||||||
}
|
}
|
||||||
lines.push('```');
|
emit('```');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
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);
|
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
|
// Trailing blank lines are what an editor accumulates from people pressing
|
||||||
// return at the end; they are not content and they grow every time.
|
// return at the end; they are not content and they grow every time.
|
||||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
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
|
/* 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
|
against the sidebar's rule, which reads as a rendering fault rather than
|
||||||
as a layout. */
|
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;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
@@ -515,11 +517,30 @@
|
|||||||
opacity: 0.7;
|
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 {
|
.editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 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
|
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
||||||
|
|||||||
Reference in New Issue
Block a user