Files
verdande/web/src/lib/components/NoteEditor.svelte
T
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

Monotype går uden om execCommand, fordi formatBlock pakker et <p> ind i
<pre>'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den <br>-fejl, der
blev rettet i #91: `textContent` på et <br> er den tomme streng.

Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i
blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet
sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i
filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode:
"echo enecho to".

Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét
sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev
skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted,
eksporteret og brugt begge steder.

Begge prøver er efterprøvet mod den gamle kode.

Fuld chromium-suite kørt to gange: 94 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:58:08 +02:00

1769 lines
57 KiB
Svelte

<script>
/**
* The note itself.
*
* Rich text, not Markdown source: somebody who writes in Apple Notes presses
* ⌘B and expects the word to go bold, not to grow asterisks. The file underneath
* is still Markdown — see $lib/richtext.js for the seam — so nothing about the
* export changes.
*
* contenteditable with execCommand. It is deprecated and it is also the only
* thing every browser agrees on; writing a selection model from scratch is weeks
* of work and a new class of bug in the one place where losing text is
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
// to hentninger af den samme liste er to, der kan blive uenige.
let { note, notes = [], people = [], onchange, onsave, onopennote } = $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.
let active = $state({});
// Loaded when the note changes, never while it is being typed in: writing to
// innerHTML puts the caret back at the start, which mid-sentence is unusable.
let loadedId = $state(null);
$effect(() => {
if (!editor || !note || note.id === loadedId) return;
// Sat *før* tegningen, ikke efter.
//
// Kaster noget herunder, er noten stadig den, der er forsøgt indlæst — og
// uden det her ville effekten prøve igen ved hver eneste ændring bagefter og
// kaste hver gang.
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;
// Én note, der ikke kan tegnes, må ikke tage ruden med sig.
//
// Det var `note.body.trim()` uden værn, og `body` er en streng fra serveren
// hver eneste gang — indtil den ikke er. Så kaster den her inde i et
// `$effect`, og en effekt, der kaster, tager komponentens opdateringer med
// sig: den forrige note bliver stående, og *ingen* note kan åbnes bagefter.
// Klikket kan ikke engang lande. Meldt ind som "jeg kan ikke skifte mellem
// noter, uanset hvilken jeg trykker på", hvilket er præcis, hvad det ser ud
// som fra den anden side.
//
// Fejlen står i konsollen frem for i en toast: den hører til den, der kan
// rette den, og en note, der viser sin egen tekst uden formatering, siger
// allerede til den, der læser den, at noget er galt.
//
// `textContent` og ikke `innerHTML` i faldbakken — teksten er ikke tegnet, og
// at lægge den ind som markup ville være at køre det, tegningen lige nægtede.
// Gemningen er ikke i fare: den skriver `draft`, som kommer fra svaret og
// ikke fra det, der står i editoren, indtil nogen taster i den.
const body = note.body ?? '';
try {
// An empty note opens on its title. That is what Apple Notes does, and it
// is also what the list needs: the first line becomes the name of the
// note, so a note whose first line is body text is a note called by its
// first sentence.
//
// Tested on the text and not on the rendered result: an empty body renders
// as one empty paragraph, not as nothing, so a falsy check here never
// fired.
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
colourCode();
} catch (e) {
editor.textContent = body;
console.error('verdande: noten kunne ikke tegnes', note.id, e);
}
});
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '@', what: 'notes.syntaxPerson' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
const STYLES = [
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
{ key: 'subheading', tag: 'h3', label: 'notes.styleSubheading' },
{ key: 'body', tag: 'p', label: 'notes.styleBody' },
{ key: 'mono', tag: 'pre', label: 'notes.styleMono' },
{ key: 'quote', tag: 'blockquote', label: 'notes.styleQuote' }
];
function apply(command, value) {
editor?.focus();
// Tags, not inline styles. Left to itself the browser writes
// <span style="font-weight: normal"> for an un-bold, which is invisible to a
// converter that reads tags — so the change is lost on the way to Markdown
// and comes back bold. Asking for tags keeps the document in a shape that
// can be written down.
try {
document.execCommand('styleWithCSS', false, false);
} catch {
// Not everywhere. The formats below still work; some of them just produce
// spans, which is why the converter reads styles as well as tags.
}
document.execCommand(command, false, value);
readState();
changed();
}
function setStyle(style) {
stylesOpen = false;
editor?.focus();
if (style.key === 'bullet') return apply('insertUnorderedList');
if (style.key === 'numbered') return apply('insertOrderedList');
// formatBlock is unreliable around <pre>. Asked to turn a monospace line back
// into body, Chromium and WebKit nest a <p> *inside* the <pre> —
// `<pre>…<p>body</p></pre>` — so the text stays monospace and there is no way
// out of the block from the menu. Do any conversion that touches a <pre> by
// hand, replacing the element rather than reformatting inside it.
//
// Hele markeringen, ikke blokken markøren står i. Den her vej spurgte efter
// `currentBlock()`, og det er ét blok — så en markering over ti linjers
// terminaludskrift gjorde den første linje til kode og lod resten stå. Det
// er den slags, man ikke opdager med to linjer og ikke kan overse med ti.
const blocks = selectedBlocks();
if (!blocks.length) return apply('formatBlock', style.tag);
if (style.tag === 'pre') {
mergeToMono(blocks);
return;
}
// Ud af monotype igen: hver blok for sig, uden at røre markeringen —
// tekstknuderne flytter med over i de nye elementer, så den range, browseren
// holder på, stadig peger på noget, der er i dokumentet. Resten klarer
// formatBlock bagefter, nu hvor der ikke er en <pre> i vejen.
const pres = blocks.filter((b) => b.tagName === 'PRE');
for (const b of pres) swapTag(b, style.tag);
if (pres.length === blocks.length) {
readState();
changed();
return;
}
apply('formatBlock', style.tag);
}
/**
* Alle de blokke, markeringen rører — ikke kun den, markøren står i.
*
* En sammenfaldet markering rører præcis ét blok, så den enkle sag opfører sig
* som før.
*/
function selectedBlocks() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount || !editor) return [];
const range = selection.getRangeAt(0);
return [...editor.children].filter((b) => range.intersectsNode(b));
}
/**
* Lægger flere blokke sammen til én monotypeblok.
*
* Én blok og ikke én pr. linje: en udskrift, man indsætter, er ét stykke kode.
* Tre blokke ved siden af hinanden ser næsten ens ud på skærmen og bliver til
* tre hegn i filen — og så kan de ikke rulles eller kopieres som det ene,
* de er.
*/
function mergeToMono(blocks) {
const pre = document.createElement('pre');
// blockText og ikke textContent: et <br> har den tomme streng som
// textContent, og det er netop <br>, der står for et linjeskift inde i en
// blok. Samme grund som da en kodeblok ikke kunne gemme mere end én linje.
pre.textContent = blocks.map((b) => blockText(b)).join('\n');
if (!pre.textContent) pre.appendChild(document.createElement('br'));
blocks[0].replaceWith(pre);
for (const b of blocks.slice(1)) b.remove();
const range = document.createRange();
range.selectNodeContents(pre);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/** Bytter et bloks tag ud og flytter indholdet med, uden at røre markøren. */
function swapTag(block, tag) {
// The copy button lives inside the <pre> so it can sit in its corner; it is
// not text and must not travel into a paragraph. addCopyButtons puts it back
// on the next render for any <pre> that still needs one.
block.querySelectorAll?.('.copy').forEach((b) => b.remove());
const el = document.createElement(tag);
while (block.firstChild) el.appendChild(block.firstChild);
if (!el.firstChild) el.appendChild(document.createElement('br'));
block.replaceWith(el);
return el;
}
/**
* Replaces a block element with one of another tag, keeping its contents and the
* caret. Used where execCommand cannot be trusted — converting to or from a
* <pre>, which it either nests or ignores.
*/
function replaceBlock(block, tag) {
const el = swapTag(block, tag);
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
readState();
changed();
}
/**
* Inline kode — `sådan her`.
*
* Den knap fandtes ikke, og `</>`, som i ethvert andet program betyder netop
* kode, sad på kildevisningen med teksten "Vis som kode" på. Så markerede man et
* ord og trykkede på den, og hele noten skiftede til Markdown. Det blev meldt
* som en fejl, og det var det også: knappen sagde ét og gjorde et andet. Nu gør
* `</>` det, tegnet siger, og kilden har fået sit eget mærke.
*
* Skrevet i hånden, fordi execCommand ikke har en kommando for kode. Markeringen
* læses som ren tekst — det er hele meningen med kode, at det ikke er formateret
* — og lægges tilbage inde i et <code>, som konverteringen allerede kender
* begge veje.
*/
function codeAround() {
const selection = window.getSelection();
let node = selection?.anchorNode;
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'CODE') return node;
node = node.parentNode;
}
return null;
}
/** Markerer ordet, markøren står i. Bruges når der ikke er markeret noget. */
function selectWordAtCaret() {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent ?? '';
let start = selection.anchorOffset;
let end = start;
while (start > 0 && !/\s/.test(text[start - 1])) start--;
while (end < text.length && !/\s/.test(text[end])) end++;
if (start === end) return;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
selection.removeAllRanges();
selection.addRange(range);
}
function selectContents(node) {
const range = document.createRange();
range.selectNodeContents(node);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function toggleCode() {
editor?.focus();
const selection = window.getSelection();
if (!selection?.rangeCount) return;
// Inde i en kodeblok er alting allerede kode; en <code> derinde kan hverken
// ses eller skrives ned, for en hegnet blok er tekst hele vejen igennem.
const block = currentBlock();
if (block?.tagName === 'PRE') return;
const existing = codeAround();
if (existing) {
// Ud igen: teksten bliver stående, mærkerne forsvinder. Markeringen lægges
// om den, så et tryk mere sætter den tilbage — knappen er en kontakt.
const text = document.createTextNode(existing.textContent ?? '');
existing.replaceWith(text);
selectContents(text);
readState();
changed();
return;
}
// Uden en markering er det ordet, markøren står i. Det er det, ⌘B gør i et
// tekstprogram, og bedre end et tomt <code>, der ikke kan ses og ikke kan
// skrives ned.
if (selection.isCollapsed) selectWordAtCaret();
const live = window.getSelection();
const text = live.toString();
if (!text) return;
// Bygget som en node frem for med `insertHTML`.
//
// Chromium renser den HTML, den får dér, gennem den samme vej som en
// indsætning: `<code>` kom ud som et `<span>` med skrifttypen og farven
// skrevet ind i sig, og et span er ikke noget, htmlToMarkdown kan skrive ned
// som kode. Målt — mærket forsvandt hver gang, og noten så rigtig ud, indtil
// den blev gemt.
const range = live.getRangeAt(0);
range.deleteContents();
const code = document.createElement('code');
code.textContent = text;
range.insertNode(code);
// Markøren efter mærket, ikke inde i det: ellers bliver det næste ord, man
// skriver, også til kode.
const after = document.createRange();
after.setStartAfter(code);
after.collapse(true);
live.removeAllRanges();
live.addRange(after);
readState();
changed();
}
/**
* Steps out of a code block on Enter.
*
* A <pre> is preformatted, so the browser answers Enter by inserting a <br> and
* staying inside it — which means a monospace last line makes every line after
* it monospace too, with no way back to body by typing. Plain Enter now leaves
* the block: the tail after the caret becomes a paragraph below the <pre>, and
* that is where the caret lands. Shift+Enter is left untouched, so a soft line
* break still writes multi-line code inside the block.
*/
function exitPre(pre) {
pre.querySelectorAll('.copy').forEach((b) => b.remove());
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// Everything from the caret to the end of the block leaves with the new line.
const tail = document.createRange();
tail.setStart(range.endContainer, range.endOffset);
tail.setEnd(pre, pre.childNodes.length);
const frag = tail.extractContents();
// A break at the head of what we carried is the empty line return was pressed
// on; it should not open the paragraph with a blank row.
while (frag.firstChild && frag.firstChild.nodeName === 'BR') frag.removeChild(frag.firstChild);
const p = document.createElement('p');
if (frag.childNodes.length) p.appendChild(frag);
// The split can leave an empty text node behind, and a block with no element
// and no text is not a caret host in Chromium — the next keystroke falls back
// into the block above. An empty paragraph needs a <br> to be stood in.
if (!p.textContent && !p.querySelector('br, img')) {
p.replaceChildren(document.createElement('br'));
}
// And the same break seen from the other side, left dangling in the <pre>.
while (pre.lastChild && pre.lastChild.nodeName === 'BR') pre.removeChild(pre.lastChild);
// Leaving an empty block behind is leaving nothing: the <pre> itself goes.
if (!pre.textContent.trim()) pre.replaceWith(p);
else pre.after(p);
const caret = document.createRange();
caret.setStart(p, 0);
caret.collapse(true);
selection.removeAllRanges();
selection.addRange(caret);
}
/** What the caret is standing in, for the toolbar's pressed states. */
function readState() {
if (!editor) return;
try {
active = {
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strike: document.queryCommandState('strikeThrough'),
bullet: document.queryCommandState('insertUnorderedList'),
numbered: document.queryCommandState('insertOrderedList'),
// Læst af DOM'en og ikke af execCommand: der findes ingen kommando
// for kode, hvilket er hele grunden til at knappen er skrevet i
// hånden herunder.
code: !!codeAround()
};
} catch {
// Some browsers throw on queryCommandState with no selection. The toolbar
// showing nothing is a smaller problem than the editor throwing.
active = {};
}
}
/**
* Farver kodeblokkene.
*
* Kun dem, markøren ikke står i. At farve blokken, mens nogen skriver i den,
* betyder at bygge dens indhold om ved hvert tastetryk og sætte markøren
* tilbage bagefter — det er den slags, der virker i ni tilfælde ud af ti og
* flytter markøren midt i en sætning i det tiende. Blokken er mørk og
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
* man læser den.
*/
/**
* En kopiér-knap på hver kodeblok.
*
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
* den findes for øjet og musen, ikke for dokumentet.
*
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
*/
function addCopyButtons() {
if (!editor) return;
for (const pre of editor.querySelectorAll('pre')) {
if (pre.querySelector('.copy')) continue;
const btn = document.createElement('button');
btn.className = 'copy';
btn.type = 'button';
btn.contentEditable = 'false';
btn.textContent = t('notes.copyCode');
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = blockText(pre);
try {
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
btn.textContent = t('notes.copied');
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
} catch {
app.toast(t('notes.copyFailed'));
}
});
pre.appendChild(btn);
}
}
/**
* Teksten i en kodeblok kommer fra `blockText` i richtext.js — ÉT sted, hvilket
* den her kommentar påstod i forvejen, mens der stod en kopi lige her.
*
* De to skal bruge det samme svar: farvelægningen, som skriver resultatet
* tilbage i blokken, og oversættelsen til Markdown, som skriver det i filen. De
* var uenige om ét tegn — kopien her læste `textContent`, og et <br> har den
* tomme streng som textContent. Så en blok, man havde skrevet med shift+retur,
* fik sine linjer skrevet sammen af farvelægningen, i det øjeblik man klikkede
* væk fra den. På skærmen, ikke kun i filen.
*/
function colourCode() {
if (!editor) return;
const inside = currentBlock();
for (const pre of editor.querySelectorAll('pre')) {
if (pre === inside || pre.contains(inside)) continue;
// Blokkens tekst uden det, vi selv har lagt oven på den.
//
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
// spiste den kode, den var sat der for at kopiere.
const code = blockText(pre);
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
if (lang) pre.setAttribute('data-lang', lang);
const painted = highlight(code, lang);
// Kun hvis der er noget at ændre: at skrive innerHTML uændret ville
// stadig rive noden op og lægge den tilbage, og det ses som et blink.
const target = pre.querySelector('code') ?? pre;
if (target.innerHTML !== painted) target.innerHTML = painted;
}
addCopyButtons();
}
function changed() {
if (!editor) return;
onchange?.(htmlToMarkdown(editor));
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([]);
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.
*
* Read from the text node the caret is in rather than from the whole document:
* a note can mention twenty projects, and only the one under the cursor is being
* written.
*/
function partialTag() {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
const before = node.textContent.slice(0, selection.anchorOffset);
// A note link, [[, is the same idea as a project tag and gets the same
// completion — checked first because it is the longer opener. The term runs
// to the caret and is anything up to the closing bracket, which has not been
// typed yet.
const note = /\[\[([^\]\n]*)$/u.exec(before);
if (note) {
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
}
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
// læse sætningen efter navnet med.
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
if (person) {
return {
kind: 'person',
node,
start: selection.anchorOffset - person[1].length,
term: person[1]
};
}
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (project) {
return {
kind: 'project',
node,
start: selection.anchorOffset - project[1].length,
term: project[1]
};
}
return null;
}
/**
* 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() {
const partial = partialTag();
if (!partial) {
suggestions = [];
return;
}
const term = partial.term.toLowerCase();
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
// 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);
suggestions =
partial.kind === 'note'
? noteMatches(term)
: partial.kind === 'person'
? personMatches(term)
: projectMatches(term);
chosen = 0;
}
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: 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.
function projectMatches(term) {
return app.projects
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
.sort((a, b) => rank(a.label, b.label, term));
}
// The same completion for [[ as for #, over the notes the page has loaded. The
// current note is left out — a note that links to itself is a loop nobody meant
// — and a note with no title yet cannot be pointed at, so it is skipped too.
function noteMatches(term) {
return notes
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
.sort((a, b) => rank(a.label, b.label, term));
}
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
function personMatches(term) {
return people
.filter((p) => p.name && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
.sort((a, b) => rank(a.label, b.label, term));
}
function rank(a, b, term) {
const al = a.toLowerCase();
const bl = b.toLowerCase();
const ap = al.startsWith(term);
const bp = bl.startsWith(term);
if (ap !== bp) return ap ? -1 : 1;
return al.localeCompare(bl, 'da');
}
const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
// A click on a note link follows it; a click anywhere else re-colours the code,
// as it did before. The link is contenteditable="false", so the click is a click
// and not a caret landing in the middle of a word.
function onEditorClick(event) {
const link = event.target.closest?.('.notelink');
if (link) {
event.preventDefault();
onopennote?.(link.getAttribute('data-note') ?? link.textContent);
return;
}
// Et billede i en note er et billede, man har indsat for at kunne se det. I
// arket er det skaleret ned til spaltens bredde — et foto fra en telefon er
// fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede,
// der ikke ser ud som et.
const image = event.target.closest?.('img');
if (image) {
event.preventDefault();
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
copied = false;
copyError = '';
return;
}
colourCode();
}
// --- billedet i fuld størrelse ------------------------------------------------
let showing = $state(null);
let copied = $state(false);
let copyError = $state('');
function closeImage() {
showing = null;
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
// findes længere, og den næste tast går ingen steder.
editor?.focus();
}
/**
* Billedet som PNG, uanset hvad det kom som.
*
* Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon
* er en JPEG. Så et billede, der ikke allerede er PNG, tegnes om — ellers
* lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan indsætte.
*/
async function asPNG(src) {
const blob = await (await fetch(src)).blob();
if (blob.type === 'image/png') return blob;
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
async function copyImage() {
copyError = '';
try {
// Løftet gives videre frem for at blive ventet af først. Safari kræver, at
// clipboard.write kaldes i selve klikket, og en hentning imellem gør
// klikket til fortid — et ClipboardItem må gerne indeholde et løfte.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': asPNG(showing.src) })]);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (e) {
// Sagt højt frem for tavst. En kopiér-knap, der ikke gør noget, er værre
// end ingen knap: man indsætter noget andet og opdager det et andet sted.
copyError = t('notes.copyFailed');
}
}
/** Replaces the half-typed tag with the whole thing. */
function accept(item) {
const partial = partialTag();
if (!partial) return;
const range = document.createRange();
const selection = window.getSelection();
if (item.kind === 'note') {
// Swallow the opening [[ as well and drop the finished link in its place,
// so it is a link the moment it is chosen — not the brackets, waiting to
// become one when the note is next opened. A space after, as its own text,
// so the caret leaves the atomic link and the next word is a word.
range.setStart(partial.node, partial.start - 2);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand(
'insertHTML',
false,
`<a class="notelink" data-note="${escAttr(item.label)}" contenteditable="false">${escHtml(item.label)}</a>`
);
document.execCommand('insertText', false, ' ');
} else {
range.setStart(partial.node, partial.start);
range.setEnd(partial.node, partial.start + partial.term.length);
selection.removeAllRanges();
selection.addRange(range);
// A trailing space, or the suggestions come straight back for the tag.
document.execCommand('insertText', false, `${item.label} `);
}
suggestions = [];
onchange?.(htmlToMarkdown(editor));
}
/** The block the caret is standing in. */
function currentBlock() {
const selection = window.getSelection();
if (!selection || !selection.anchorNode || !editor) return null;
let node = selection.anchorNode;
while (node && node.parentNode !== editor) node = node.parentNode;
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
}
/**
* Title for the first line, body for everything after it.
*
* Run after the browser has split the block on Enter, because until then there
* is no second block to change. Pressing return at the end of a heading leaves
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
}
/**
* 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' });
});
}
/**
* 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.
if (suggestions.length) {
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') {
event.preventDefault();
accept(suggestions[chosen]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
suggestions = [];
return;
}
}
// 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
// monospace last line traps everything typed after it in monospace.
const block = currentBlock();
if (block && block.tagName === 'PRE') {
event.preventDefault();
exitPre(block);
changed();
return;
}
// After the split, not before it.
setTimeout(() => {
keepTitleFirst();
changed();
}, 0);
return;
}
// The shortcuts people already have in their fingers. execCommand handles
// bold and italic itself; underline and strikethrough are claimed here so
// they behave the same way.
if (!(event.metaKey || event.ctrlKey)) return;
const key = event.key.toLowerCase();
if (key === 'u') {
event.preventDefault();
apply('underline');
} else if (key === 'e') {
event.preventDefault();
toggleCode();
} else if (key === 'x' && event.shiftKey) {
event.preventDefault();
apply('strikeThrough');
}
}
// Pasted text arrives as whatever the source was — a web page brings its fonts,
// its colours and its layout with it. Only the words are wanted.
/**
* Filer ind i noten — indsat eller trukket.
*
* Importen kunne tage billeder med, og intet andet kunne. At indsætte et
* skærmbillede i en note er det mest almindelige, der findes i et noteprogram,
* og det gjorde ingenting overhovedet: indsætningen tog kun `text/plain` og
* smed resten væk, og der var slet ingen håndtering af et træk.
*
* Filen lægges op og skrives ind som `![](…)`. Ikke som en data-URL: en note
* er Markdown i en database, og et fotografi fra en telefon som base64 midt i
* teksten ville gøre noten til fire megabyte tekst, som skal læses hver gang
* listen tegnes.
*
* En note, der ikke er gemt endnu, kan ikke bære en fil — den har intet id at
* hænge den på. Derfor gemmes den først, og det siges ikke til nogen, fordi
* det er en teknisk detalje og ikke noget, brugeren har bedt om at vide.
*/
async function insertFiles(files) {
const list = [...files].filter((f) => f && f.size > 0);
if (!list.length || !note?.id) return false;
uploading = true;
try {
for (const file of list) {
const saved = await api.uploadNoteFile(note.id, file);
// execCommand frem for at bygge noden selv: det er det, der lægger
// handlingen i browserens egen fortryd-historik, så ⌘Z tager billedet
// ud igen ligesom det tager et ord ud.
editor?.focus();
document.execCommand('insertHTML', false,
`<img src="${saved.url}" alt="">`);
}
changed();
onsave?.();
return true;
} catch (e) {
app.toast(humanMessage(e));
return false;
} finally {
uploading = false;
}
}
let uploading = $state(false);
function onpaste(event) {
// Filer først. Et skærmbillede i udklipsholderen kommer med som en fil og
// som ingenting andet, så hvis der er en, er det den, der skal ind.
const files = event.clipboardData?.files ?? [];
if (files.length) {
event.preventDefault();
insertFiles(files);
return;
}
event.preventDefault();
const text = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, text);
changed();
}
/**
* Trukket ind udefra.
*
* `preventDefault` på begge: uden den på dragover kommer der ingen drop, og
* uden den på drop åbner browseren filen i stedet — man mister det, man var i
* gang med at skrive, til en fane med et billede i.
*
* Kun filer. Et træk inde fra appen selv — en opgave, en note — bærer vores
* egen nyttelast og skal falde igennem til den, der lyttede efter den.
*/
function ondragover(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dropping = true;
}
function ondrop(event) {
const files = event.dataTransfer?.files ?? [];
dropping = false;
if (!files.length) return;
event.preventDefault();
insertFiles(files);
}
let dropping = $state(false);
</script>
<div class="wrap" bind:this={wrap}>
<!-- 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
appears to do nothing at all, which is how it read the first time. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="toolbar" onmousedown={(e) => e.preventDefault()}>
<div class="styles">
<button
class="aa"
onclick={() => (stylesOpen = !stylesOpen)}
aria-expanded={stylesOpen}
aria-label={t('notes.format')}
title={t('notes.format')}>Aa</button
>
{#if stylesOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="menu" role="menu">
{#each STYLES as style}
<button class={style.key} role="menuitem" onclick={() => setStyle(style)}>
{t(style.label)}
</button>
{/each}
<hr />
<button
class:on={active.bullet}
role="menuitem"
onclick={() => setStyle({ key: 'bullet' })}>• {t('notes.styleBullet')}</button
>
<button
class:on={active.numbered}
role="menuitem"
onclick={() => setStyle({ key: 'numbered' })}>1. {t('notes.styleNumbered')}</button
>
</div>
{/if}
</div>
<span class="sep" aria-hidden="true"></span>
<button class:on={active.bold} onclick={() => apply('bold')} title="⌘B" aria-label={t('notes.bold')}>
<strong>B</strong>
</button>
<button class:on={active.italic} onclick={() => apply('italic')} title="⌘I" aria-label={t('notes.italic')}>
<em>I</em>
</button>
<button class:on={active.underline} onclick={() => apply('underline')} title="⌘U" aria-label={t('notes.underline')}>
<u>U</u>
</button>
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
<s>S</s>
</button>
<!-- `</>` hører til her, mellem de andre mærker, fordi kode er et mærke som
fed og kursiv. Den sad på kildevisningen, og det var en fælde: tegnet
betyder kode overalt ellers, så folk markerede et ord og fik hele noten
om til Markdown. -->
<button
class:on={active.code}
onclick={toggleCode}
title="⌘E"
aria-pressed={!!active.code}
aria-label={t('notes.code')}
>
<span class="codemark">&lt;/&gt;</span>
</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å — og med Markdown-mærket på, som er præcis
det, den viser. Et tegn, ingen forveksler med et format. -->
<button
class="src"
class:on={asSource}
onclick={toggleSource}
aria-pressed={asSource}
aria-label={t('notes.showSource')}
title={t('notes.showSource')}
>
<svg viewBox="0 0 208 128" aria-hidden="true">
<rect
x="5"
y="5"
width="198"
height="118"
rx="14"
fill="none"
stroke="currentColor"
stroke-width="12"
/>
<path
d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zM155 98l-30-33h20V30h20v35h20z"
fill="currentColor"
/>
</svg>
</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"
aria-multiline="true"
aria-label={t('notes.body')}
spellcheck="true"
oninput={changed}
onblur={() => {
colourCode();
onsave?.();
}}
onkeyup={() => {
readState();
readSuggestions();
}}
onclick={onEditorClick}
onmouseup={readState}
{onkeydown}
{onpaste}
{ondragover}
{ondrop}
ondragleave={() => (dropping = false)}
class:dropping
></div>
{#if suggestions.length}
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
står stille, mens listen snævres ind. -->
<ul
class="suggestions"
role="listbox"
bind:this={list}
style:top={menuAt ? `${menuAt.top}px` : null}
style:left={menuAt ? `${menuAt.left}px` : null}
>
{#each suggestions as item, i (item.kind + item.id)}
<li>
<button
class:on={i === chosen}
role="option"
aria-selected={i === chosen}
onmousedown={(e) => {
e.preventDefault();
accept(item);
}}
>
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else if item.kind === 'person'}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
#{item.label}
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<!-- What the text understands, said where it is being typed. The same idea as
the line under the quick-add box: a note is the other place in this program
where what you type is read for meaning. -->
<p class="legend">
{#each SYNTAX as item}
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
{/each}
</p>
</div>
<!-- Billedet i fuld størrelse. Uden for .wrap, fordi editoren har sin egen
rulning og sin egen stablingskontekst — et lag inde i den ville blive klippet
af netop det ark, det skal lægge sig over. -->
{#if showing}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="shot" onclick={(e) => e.stopPropagation()}>
<img src={showing.src} alt={showing.alt} />
<div class="tools">
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
</div>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
{/if}
<svelte:window
onkeydown={(e) => {
if (showing && e.key === 'Escape') {
e.stopPropagation();
closeImage();
}
}}
/>
<style>
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
står alene — det er hele grunden til at åbne det. */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
}
.shot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
max-width: 100%;
max-height: 100%;
}
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
passe sig ind i den. */
.shot img {
min-height: 0;
max-width: 100%;
max-height: calc(100vh - 8rem);
object-fit: contain;
border-radius: var(--radius);
}
.tools {
display: flex;
gap: var(--s2);
align-items: center;
flex-wrap: wrap;
}
.tools button,
.tools a {
padding: var(--s2) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
text-decoration: none;
}
.tools button:hover,
.tools a:hover {
border-color: var(--accent);
}
.bad {
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
}
/* Markøren siger, at billedet er til at klikke på, før man prøver. */
.page :global(img) {
cursor: zoom-in;
}
.wrap {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--s1);
padding: 0 0 var(--s2);
border-bottom: 1px solid var(--line);
}
.toolbar button {
min-width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
.toolbar button:hover {
background: var(--surface);
color: var(--ink);
}
.toolbar button.on {
background: var(--surface-raised);
color: var(--ink);
}
.sep {
width: 1px;
height: 18px;
background: var(--line);
margin: 0 var(--s1);
}
.styles {
position: relative;
}
.aa {
font-weight: 560;
}
.menu {
position: absolute;
top: calc(100% + var(--s1));
left: 0;
z-index: 20;
min-width: 210px;
padding: var(--s1);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
display: flex;
flex-direction: column;
}
.menu button {
width: 100%;
text-align: left;
padding: var(--s1) var(--s2);
height: auto;
min-height: 30px;
}
.menu hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--s1) 0;
}
/* The menu shows each style in the style it applies, the way Apple Notes does.
Reading the word "Overskrift" set as one is faster than reading a label. */
.menu .title {
font-size: 1.35rem;
font-weight: 700;
color: var(--ink);
}
.menu .heading {
font-size: 1.1rem;
font-weight: 640;
color: var(--ink);
}
.menu .subheading {
font-weight: 620;
color: var(--ink);
}
.menu .mono {
font-family: var(--mono, ui-monospace, monospace);
}
.menu .quote {
border-left: 2px solid var(--line-strong);
padding-left: var(--s2);
color: var(--ink-muted);
}
.suggestions {
position: absolute;
z-index: 30;
list-style: none;
margin: 0;
padding: var(--s1);
min-width: 200px;
background: var(--surface-raised);
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 {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
text-align: left;
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
color: var(--ink-muted);
font-size: var(--text-sm);
}
/* A note suggestion wears the brackets that open it where a project wears its
colour, so the two kinds are told apart at a glance in the same list. */
.suggestions .dot.note {
width: auto;
height: auto;
background: none;
color: var(--ink-faint);
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.85em;
line-height: 1;
}
.suggestions button.on {
background: var(--surface);
color: var(--ink);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--s1) var(--s3);
padding-top: var(--s2);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.legend kbd {
font-family: var(--mono, ui-monospace, monospace);
color: var(--ink-muted);
}
.wrap {
position: relative;
}
.page {
/* Et token eller en URL er ét ord, der er bredere end skærmen. Uden det her
får siden en vandret rullebjælke, og teksten forsvinder ud til højre. */
overflow-wrap: anywhere;
flex: 1;
min-height: 50vh;
padding: var(--s3) 0;
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. */
/* Bredden vokser med ruden, men ikke i det uendelige.
*
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
* bredde, blev klemt sammen uden grund.
*
* clamp frem for et tal: en smal rude får det hele, en bred får en
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
/* Grænsen sættes som polstring og ikke som bredde, og det er rullebjælken,
* der afgør det.
*
* Det er *dette* element, der ruller. Var det også det, der blev smallere,
* fulgte rullebjælken med ind: den stod ved tekstsøjlens kant med en
* håndflade tom rude til højre for sig og svævede midt i arket. Med
* polstring er elementet lige så bredt som ruden — bjælken bliver ved
* kanten, hvor den hører til — og teksten er nøjagtig lige så bred som før.
*
* `max()` mod nul, fordi en smal rude ellers ville få negativ polstring, og
* `border-box` er sat globalt, så bredden er hele boksen og ikke boksen plus
* polstringen. Uden det ville det her give en vandret rullebjælke. */
width: 100%;
padding-right: max(0px, calc(100% - clamp(38rem, 92%, 68rem)));
}
.hidden {
display: none;
}
/* Mens en fil holdes over noten. En ramme frem for en farvet flade: teksten
under skal stadig kunne læses, så man kan se hvor billedet lander. */
.page.dropping {
outline: 2px dashed var(--accent);
outline-offset: 4px;
border-radius: var(--radius-sm);
}
/* 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;
}
/* Kodeknappen bærer sit eget tegn i monospace — `</>` sat med brødskriften
ligner en tastefejl frem for et mærke. */
.codemark {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.src {
display: inline-flex;
align-items: center;
justify-content: center;
}
.src svg {
width: 17px;
height: auto;
}
/* The document styles. These are the whole of "it should look like Apple Notes":
a title that is a title, and body text that is comfortable to read for a page
at a time. */
.page :global(h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 var(--s2);
line-height: 1.25;
}
.page :global(h2) {
font-size: 1.2rem;
font-weight: 640;
margin: var(--s3) 0 var(--s1);
}
.page :global(h3) {
font-size: 1rem;
font-weight: 620;
margin: var(--s3) 0 var(--s1);
}
.page :global(p) {
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: 2.2em;
}
.page :global(li) {
margin-bottom: var(--s1);
}
/* Et billede fylder sin egen bredde og ikke mere. Et foto fra en telefon er
fire tusind pixels bredt, og uden det her stikker det ud af arket. */
.page :global(img) {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
display: block;
margin: var(--s2) 0;
}
/* A note link reads as a link: the accent colour, underlined on hover, and a
pointer so it is plainly something to click rather than to edit. */
.page :global(a.notelink) {
color: var(--accent);
text-decoration: none;
cursor: pointer;
border-radius: var(--radius-sm);
}
.page :global(a.notelink:hover) {
text-decoration: underline;
}
.page :global(blockquote) {
margin: 0 0 var(--s2);
padding-left: var(--s3);
border-left: 2px solid var(--line-strong);
color: var(--ink-muted);
}
/* Kopiér-knappen, øverst til højre i blokken.
*
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
* opmærksomhed fra koden.
*
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
.page :global(pre) {
position: relative;
}
.page :global(pre .copy) {
position: absolute;
z-index: 1;
top: 6px;
right: 6px;
font-family: var(--font);
font-size: var(--text-xs);
padding: 2px var(--s2);
border-radius: var(--radius-sm);
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.55);
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.page :global(pre:hover .copy),
.page :global(pre .copy:hover),
.page :global(pre .copy:focus-visible) {
background: rgb(255 255 255 / 0.2);
color: #fff;
}
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
uden at man skal læse et ord. Farverne er de samme i lys og mørk tilstand:
en terminal er sort, også midt på dagen. */
.page :global(pre) {
margin: 0 0 var(--s3);
padding: var(--s3);
border-radius: var(--radius);
background: #14181b;
color: #d7dde3;
font-family: var(--mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
position: relative;
}
/* Sproget i hjørnet, når blokken siger hvad den er. */
.page :global(pre[data-lang]:not([data-lang=''])::after) {
content: attr(data-lang);
position: absolute;
top: 4px;
right: 8px;
font-size: 0.625rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5a656e;
}
.page :global(pre code) {
font: inherit;
color: inherit;
}
.page :global(.tok-comment) {
color: #6b7a85;
font-style: italic;
}
.page :global(.tok-string) {
color: #9ecb8a;
}
.page :global(.tok-number) {
color: #d9a25f;
}
.page :global(.tok-keyword) {
color: #7fb3e8;
}
.page :global(.tok-key) {
color: #7fb3e8;
}
.page :global(.tok-bool) {
color: #c48fd6;
}
.page :global(.tok-variable) {
color: #d9a25f;
}
.page :global(.tok-flag) {
color: #c48fd6;
}
.page :global(.tok-punct) {
color: #8a949c;
}
/* Prompten i en terminaludskrift: det første øjet finder, når man leder efter
hvor en kommando begynder. */
.page :global(.tok-prompt) {
color: #6fbf8f;
font-weight: 600;
}
.page :global(code) {
font-family: var(--mono, ui-monospace, monospace);
color: var(--accent);
}
</style>