feat: Front-matter properties editor and inline math bug fix

This commit is contained in:
Your Name committed 2026-10-09 22:39:37 +02:00
commit 2952e539aa
4 files changed
+1640

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
<script>
import Icon from './Icon.svelte';
import Editor from './Editor.svelte';
import CoverPicker from './CoverPicker.svelte';
import Backlinks from './Backlinks.svelte';
import Board from './Board.svelte';
import Outline from './Outline.svelte';
import { store, iconOf, coverOf } from '$lib/state.svelte.js';
import { COVERS } from '$lib/icons.js';
import IconPicker from './IconPicker.svelte';
import { api } from '$lib/api.js';
let doc = $derived(store.doc);
let page = $derived(store.current);
let icon = $derived(iconOf({ icon: doc?.meta.icon, title: doc?.title }));
let cover = $derived(coverOf({ cover: doc?.meta.cover, title: doc?.title }));
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
let created = $derived(doc?.meta.created || page?.created || null);
let scrollerEl = $state();
let titleEl = $state();
let iconEl = $state();
let picking = $state(false);
let coverEl = $state();
let pickingCover = $state(false);
let coverBtnEl = $state();
let tagInput = $state(null);
let removing = $state(null);
// Keep the title element in sync when the page changes or a rename settles, but never while typing.
$effect(() => {
const d = store.doc;
if (!titleEl || !d) return;
if (document.activeElement === titleEl) return;
const blankNew = store.fresh === d.id && /^Untitled( \d+)?$/.test(d.title);
titleEl.textContent = blankNew ? '' : d.title;
if (blankNew) requestAnimationFrame(() => titleEl && titleEl.focus());
});
const fmtDate = (d) => {
if (!d) return '—';
const t = new Date(String(d).length === 10 ? d + 'T12:00:00' : d);
return isNaN(t) ? String(d) : t.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
function restart(el, cls) {
if (!el) return;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
}
function setIcon(v) {
picking = false;
store.setMeta({ icon: v });
restart(iconEl, 'spin');
iconEl?.focus();
}
function setCover(v) {
pickingCover = false;
store.setMeta({ cover: v });
restart(coverEl, 'enter');
}
function onTitleInput() {
if (titleEl.innerHTML === '<br>') titleEl.innerHTML = '';
store.titleDraft = titleEl.textContent;
}
function onTitleKey(e) {
if (e.key === 'Enter' || (e.key === 'ArrowDown' && atEnd())) {
e.preventDefault();
titleEl.blur();
store.editor?.focusFirst();
}
if (e.key === 'Escape') titleEl.blur();
}
function atEnd() {
const s = getSelection();
if (!s.rangeCount || !s.isCollapsed) return false;
const r = document.createRange();
r.selectNodeContents(titleEl);
r.setStart(s.focusNode, s.focusOffset);
return r.toString().length === 0;
}
function onTitlePaste(e) {
e.preventDefault();
document.execCommand('insertText', false, e.clipboardData.getData('text/plain').replace(/\s*\n\s*/g, ' '));
}
function toTitleEnd() {
if (!titleEl) return;
titleEl.focus();
const r = document.createRange();
r.selectNodeContents(titleEl);
r.collapse(false);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
function addTag(commit) {
const v = String(tagInput || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
tagInput = null;
if (commit && v && !tags.includes(v)) store.setMeta({ tags: [...tags, v] });
}
function removeTag(t) {
removing = t;
setTimeout(() => {
store.setMeta({ tags: tags.filter((x) => x !== t) });
removing = null;
}, 180);
}
function focusOnMount(el) { el.focus(); }
let addingProp = $state(false);
let newPropEl = $state();
function updateMeta(k, v) {
if (Array.isArray(doc.meta[k])) {
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
} else {
store.setMeta({ [k]: v });
}
}
function removeMeta(k) {
const copy = { ...doc.meta };
delete copy[k];
store.doc = { ...doc, meta: copy };
store.setMeta({ [k]: undefined });
}
function renameMeta(oldK, newK) {
newK = newK.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (!newK || newK === oldK || doc.meta[newK] !== undefined) return;
const v = doc.meta[oldK];
const copy = { ...doc.meta };
delete copy[oldK];
copy[newK] = v;
store.doc = { ...doc, meta: copy };
store.setMeta({ [oldK]: undefined, [newK]: v });
}
function commitNewProp(k) {
k = k.trim().replace(/[^a-zA-Z0-9_-]/g, '-');
if (k && !doc.meta[k] && !['icon', 'cover', 'tags', 'created'].includes(k)) {
store.setMeta({ [k]: '' });
}
addingProp = false;
}
</script>
<div class="pagewrap">
<div class="scroller" bind:this={scrollerEl}>
{#if doc}
<datalist id="all-tags">
{#each store.tagCounts as [tag]}
<option value={tag}></option>
{/each}
</datalist>
{#key doc.version}
<div class="cover enter" bind:this={coverEl} style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}">
<button class="cover-btn" bind:this={coverBtnEl} onclick={() => (pickingCover = !pickingCover)}><Icon name="image" />Change cover</button>
</div>
<article class="page enter" class:wide={store.isBoard}>
<div class="head">
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}>
<Icon name={icon} />
</button>
<h1
class="title"
bind:this={titleEl}
contenteditable="true"
spellcheck="false"
data-ph="Untitled"
oninput={onTitleInput}
onkeydown={onTitleKey}
onblur={() => store.commitTitle(titleEl.textContent)}
onpaste={onTitlePaste}
></h1>
<div class="props">
<div class="prop">
<span class="pk"><Icon name="hash" />Tags</span>
<div class="pv tagv">
{#each tags as t (t)}
<span class="chip in" class:out={removing === t}>
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
</span>
{/each}
{#if tagInput !== null}
<input
class="chip-in"
list="all-tags"
placeholder="tag name"
aria-label="New tag"
bind:value={tagInput}
use:focusOnMount
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTag(true); }
if (e.key === 'Escape') { e.preventDefault(); addTag(false); }
}}
onblur={() => addTag(true)}
/>
{:else}
<button class="chip-add" onclick={() => (tagInput = '')}><Icon name="plus" />Add</button>
{/if}
</div>
</div>
<div class="prop">
<span class="pk"><Icon name="clock" />Created</span>
<span class="pv">{fmtDate(created)}</span>
</div>
<div class="prop">
<span class="pk"><Icon name="file" />File</span>
<button class="pv mono pv-link" title="Show in folder" onclick={() => api.reveal(doc.id)}>{store.relPath(doc.id)}</button>
</div>
{#each Object.keys(doc.meta).filter(k => doc.meta[k] !== undefined && !['icon', 'cover', 'tags', 'created'].includes(k)) as pk (pk)}
<div class="prop">
<span class="pk">
<Icon name="list" />
<input class="custom-pk" value={pk} onchange={(e) => renameMeta(pk, e.target.value)} />
</span>
<div style="display:flex; gap: 4px; align-items: center; width: 100%;">
<input
class="pv custom-pv"
value={Array.isArray(doc.meta[pk]) ? doc.meta[pk].join(', ') : doc.meta[pk]}
onchange={(e) => updateMeta(pk, e.target.value)}
placeholder="Empty"
/>
<button class="chip-x" aria-label="Remove property" onclick={() => removeMeta(pk)}><Icon name="x" /></button>
</div>
</div>
{/each}
{#if addingProp}
<div class="prop">
<span class="pk"><Icon name="list" /><input class="custom-pk" bind:this={newPropEl} placeholder="Key" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => commitNewProp(e.target.value)} /></span>
<span class="pv custom-pv"></span>
</div>
{/if}
<div class="prop">
<span class="pk"></span>
<div class="pv"><button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button></div>
</div>
</div>
</div>
{#if !store.isBoard}
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
<Backlinks id={doc.id} />
{/if}
</article>
{#if pickingCover}
<CoverPicker
anchor={coverBtnEl}
current={cover}
onpick={setCover}
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
/>
{/if}
{#if picking}
<IconPicker
anchor={iconEl}
current={doc.meta.icon || ''}
onpick={setIcon}
onremove={() => setIcon('')}
onclose={() => { picking = false; iconEl?.focus(); }}
/>
{/if}
{#if store.isBoard}
<Board body={doc.body} />
<div class="page wide"><Backlinks id={doc.id} /></div>
{/if}
{/key}
{:else}
<div class="empty-page">
<p>This folder has no pages yet.</p>
<button class="btn primary" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" />New page</button>
</div>
{/if}
</div>
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
</div>