2026-08-19 13:07:49 +02:00
|
|
|
<script>
|
|
|
|
|
/**
|
|
|
|
|
* Notes.
|
|
|
|
|
*
|
|
|
|
|
* A list on the left, the note itself on the right. Not a grid of cards: a note
|
|
|
|
|
* is read by its first line far more often than by its whole self, and a list
|
|
|
|
|
* shows twenty of those where a grid shows six.
|
|
|
|
|
*
|
|
|
|
|
* Markdown in a plain textarea for now. The live preview — where the marks show
|
|
|
|
|
* only in the line the cursor is in — is the next piece and the expensive one;
|
|
|
|
|
* this is the shape it will sit inside, and everything else is already true
|
|
|
|
|
* without it.
|
|
|
|
|
*/
|
|
|
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
|
|
|
import { app } from '$lib/stores.svelte.js';
|
|
|
|
|
import { t } from '$lib/i18n.svelte.js';
|
2026-08-19 13:38:18 +02:00
|
|
|
import { linesOf } from '$lib/markdown.js';
|
2026-08-19 13:07:49 +02:00
|
|
|
|
|
|
|
|
let notes = $state([]);
|
|
|
|
|
let selected = $state(null);
|
|
|
|
|
let query = $state('');
|
|
|
|
|
let status = $state('loading');
|
|
|
|
|
let saving = $state(false);
|
|
|
|
|
|
|
|
|
|
// The text being edited, kept apart from the note it came from so a save that
|
|
|
|
|
// arrives late cannot overwrite what has been typed since.
|
|
|
|
|
let draft = $state('');
|
|
|
|
|
let timer;
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
load(query);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function load(q) {
|
|
|
|
|
try {
|
|
|
|
|
notes = (await api.notes(q ? { q } : {})).notes ?? [];
|
|
|
|
|
status = 'ready';
|
|
|
|
|
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
status = 'failed';
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function open(note) {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
selected = note;
|
|
|
|
|
draft = note?.body ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function create() {
|
|
|
|
|
try {
|
|
|
|
|
const note = await api.createNote({ body: '' });
|
|
|
|
|
notes = [note, ...notes];
|
|
|
|
|
open(note);
|
|
|
|
|
document.querySelector('.editor textarea')?.focus();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Saved as you stop typing rather than on a button.
|
|
|
|
|
*
|
|
|
|
|
* A note is the one place in this program where an hour of writing can be lost
|
|
|
|
|
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
|
|
|
|
|
* delay is long enough that a sentence is one save and not twenty.
|
|
|
|
|
*/
|
|
|
|
|
function typed() {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
timer = setTimeout(save, 700);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
const id = selected.id;
|
|
|
|
|
const body = draft;
|
|
|
|
|
saving = true;
|
|
|
|
|
try {
|
|
|
|
|
const saved = await api.updateNote(id, { body });
|
|
|
|
|
notes = notes.map((n) => (n.id === id ? saved : n));
|
|
|
|
|
// Only if it is still the one on screen: switching notes mid-save must not
|
|
|
|
|
// drag the previous one's title back onto this one.
|
|
|
|
|
if (selected?.id === id) selected = saved;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
saving = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function remove(note) {
|
|
|
|
|
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteNote(note.id);
|
|
|
|
|
notes = notes.filter((n) => n.id !== note.id);
|
|
|
|
|
if (selected?.id === note.id) open(notes[0] ?? null);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// What the note points at, for the panel under the editor.
|
|
|
|
|
let links = $derived(selected?.links ?? []);
|
2026-08-19 13:38:18 +02:00
|
|
|
|
|
|
|
|
// The styled twin of what is in the textarea. Recomputed on every keystroke,
|
|
|
|
|
// which sounds expensive and is not: it is a split and a handful of regexes
|
|
|
|
|
// over a page of text, and it runs in the same frame as the character that
|
|
|
|
|
// caused it.
|
|
|
|
|
let lines = $derived(linesOf(draft));
|
2026-08-19 13:07:49 +02:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
|
|
|
|
|
|
|
|
|
<div class="notes">
|
|
|
|
|
<aside>
|
|
|
|
|
<div class="head">
|
|
|
|
|
<h1>{t('notes.title')}</h1>
|
|
|
|
|
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
class="search"
|
|
|
|
|
type="search"
|
|
|
|
|
bind:value={query}
|
|
|
|
|
placeholder={t('notes.search')}
|
|
|
|
|
aria-label={t('notes.search')}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{#if status === 'loading'}
|
|
|
|
|
<p class="hint">{t('history.loading')}</p>
|
|
|
|
|
{:else if !notes.length}
|
|
|
|
|
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<ul>
|
|
|
|
|
{#each notes as note (note.id)}
|
|
|
|
|
<li>
|
|
|
|
|
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
|
|
|
|
|
<strong>{note.title || t('notes.untitled')}</strong>
|
|
|
|
|
<span class="preview">{note.body.slice(0, 80)}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
{/each}
|
|
|
|
|
</ul>
|
|
|
|
|
{/if}
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<section class="editor">
|
|
|
|
|
{#if selected}
|
2026-08-19 13:38:18 +02:00
|
|
|
<!-- The mirror sits behind the textarea and must match it exactly: same font,
|
|
|
|
|
same size, same padding, same wrapping. Any difference shows up as
|
|
|
|
|
formatting that drifts away from the text as the note grows.
|
|
|
|
|
|
|
|
|
|
A contenteditable would let the text be styled directly and would also
|
|
|
|
|
break undo, autocorrect and IME input — a bad trade in the one place in
|
|
|
|
|
this program where somebody writes for half an hour at a stretch. -->
|
|
|
|
|
<div class="paper">
|
|
|
|
|
<div class="mirror" aria-hidden="true">
|
|
|
|
|
{#each lines as line}
|
|
|
|
|
<div class={line.block}>
|
|
|
|
|
{#each line.parts as part}
|
|
|
|
|
{#if part.kind}<span class={part.kind}>{part.text}</span>{:else}{part.text}{/if}
|
|
|
|
|
{/each}{#if !line.parts.length} {/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
bind:value={draft}
|
|
|
|
|
oninput={typed}
|
|
|
|
|
onblur={save}
|
|
|
|
|
spellcheck="true"
|
|
|
|
|
aria-label={t('notes.body')}
|
|
|
|
|
placeholder={t('notes.placeholder')}
|
|
|
|
|
></textarea>
|
|
|
|
|
</div>
|
2026-08-19 13:07:49 +02:00
|
|
|
|
|
|
|
|
<footer>
|
|
|
|
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
|
|
|
|
{#if links.length}
|
|
|
|
|
<span class="links">
|
|
|
|
|
{#each links as link}
|
|
|
|
|
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
|
|
|
|
{/each}
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
|
|
|
|
</footer>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="empty">{t('notes.pickOne')}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.notes {
|
2026-08-19 13:33:54 +02:00
|
|
|
/* Air above the heading, so it does not sit against the top bar. */
|
|
|
|
|
padding-top: var(--s3);
|
2026-08-19 13:07:49 +02:00
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
|
|
|
|
gap: var(--s4);
|
|
|
|
|
height: 100%;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
min-height: 0;
|
|
|
|
|
border-right: 1px solid var(--line);
|
|
|
|
|
padding-right: var(--s3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.head {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
font-size: var(--text-xl);
|
2026-08-19 13:33:54 +02:00
|
|
|
line-height: 1.2;
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.new {
|
|
|
|
|
font-size: var(--text-lg);
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
padding: 0 var(--s2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.new:hover {
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ul {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
text-align: left;
|
|
|
|
|
padding: var(--s2);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row:hover {
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row.on {
|
|
|
|
|
background: var(--surface-raised);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.row strong {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
font-weight: 560;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.preview {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.editor {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:38:18 +02:00
|
|
|
/* Everything below exists to keep two layers in lockstep. The shared rules are
|
|
|
|
|
set on both at once on purpose: a font-size on one and not the other is a
|
|
|
|
|
whole line of drift by the bottom of a long note. */
|
|
|
|
|
.paper {
|
|
|
|
|
position: relative;
|
2026-08-19 13:07:49 +02:00
|
|
|
flex: 1;
|
|
|
|
|
min-height: 50vh;
|
2026-08-19 13:38:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.paper textarea,
|
|
|
|
|
.paper .mirror {
|
2026-08-19 13:07:49 +02:00
|
|
|
font: inherit;
|
2026-08-19 13:38:18 +02:00
|
|
|
font-size: var(--text-base, 1rem);
|
2026-08-19 13:07:49 +02:00
|
|
|
line-height: 1.6;
|
|
|
|
|
padding: var(--s2) 0;
|
2026-08-19 13:38:18 +02:00
|
|
|
border: 0;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
overflow-wrap: break-word;
|
|
|
|
|
word-break: normal;
|
|
|
|
|
tab-size: 4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
resize: none;
|
|
|
|
|
background: none;
|
|
|
|
|
/* Transparent, so what is read is the mirror underneath. The caret keeps its
|
|
|
|
|
own colour or there would be nothing to type against. */
|
|
|
|
|
color: transparent;
|
|
|
|
|
caret-color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea::selection {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: var(--accent-ink);
|
2026-08-19 13:07:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:38:18 +02:00
|
|
|
.mirror {
|
|
|
|
|
position: absolute;
|
|
|
|
|
inset: 0;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Headings change weight and colour, never size: a larger font would take more
|
|
|
|
|
lines than the textarea does and everything below it would slide. */
|
|
|
|
|
.mirror .h1,
|
|
|
|
|
.mirror .h2,
|
|
|
|
|
.mirror .h3,
|
|
|
|
|
.mirror .h4,
|
|
|
|
|
.mirror .h5,
|
|
|
|
|
.mirror .h6 {
|
|
|
|
|
font-weight: 620;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .h1 {
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .quote {
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .bold {
|
|
|
|
|
font-weight: 620;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .italic {
|
|
|
|
|
font-style: italic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .code {
|
|
|
|
|
font-family: var(--mono, ui-monospace, monospace);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.mirror .tag,
|
|
|
|
|
.mirror .wikilink {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:07:49 +02:00
|
|
|
footer {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
padding-top: var(--s2);
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.links {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.link {
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
padding: 0 var(--s1);
|
|
|
|
|
font-family: var(--mono, ui-monospace, monospace);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.remove {
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.remove:hover {
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty,
|
|
|
|
|
.hint {
|
|
|
|
|
color: var(--ink-faint);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
.notes {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
aside {
|
|
|
|
|
border-right: 0;
|
|
|
|
|
padding-right: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|