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>
1769 lines
57 KiB
Svelte
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const escAttr = (s) => escHtml(s).replace(/"/g, '"');
|
|
|
|
// 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"></></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>
|