feat: Front-matter properties editor and inline math bug fix
This commit is contained in:
commit
2952e539aa
4 files changed
+1640
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user