feat(ui): components, routes and styles
Sidebar (lazy tree, keyboard navigation, saved searches), page view with typed properties, tasks, tags, calendar and clip dialogs, settings with shortcut rebinding, canvas, database and board views. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0587cb267
commit
27fafa0bcd
39 files changed
+8744
-94
No files matched your search
@@ -6,9 +6,14 @@
|
||||
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 { COVERS, isImageCover } from '$lib/icons.js';
|
||||
import { tagHex } from '$lib/tagColors.js';
|
||||
import IconPicker from './IconPicker.svelte';
|
||||
import { api } from '$lib/api.js';
|
||||
import Database from './Database.svelte';
|
||||
import PropCell from './PropCell.svelte';
|
||||
import { api, errorText } from '$lib/api.js';
|
||||
import { toast } from '$lib/toast.svelte.js';
|
||||
import { read, write, typeInfo, computeFormulas, TYPES } from '$lib/database.js';
|
||||
|
||||
let doc = $derived(store.doc);
|
||||
let page = $derived(store.current);
|
||||
@@ -17,6 +22,27 @@
|
||||
let tags = $derived(Array.isArray(doc?.meta.tags) ? doc.meta.tags : []);
|
||||
let created = $derived(doc?.meta.created || page?.created || null);
|
||||
|
||||
// When this page is a record of a database: that database's properties, shown here with their own editors.
|
||||
let schema = $state(null); // { dbId, properties }
|
||||
$effect(() => {
|
||||
const parent = page && page.parent;
|
||||
const owner = parent && store.pages.find((x) => x.id === parent);
|
||||
const mine = doc && doc.id;
|
||||
if (!owner || !owner.db || store.isDatabase) { schema = null; return; }
|
||||
store.readSchema(parent).then((s) => { if (store.doc && store.doc.id === mine) schema = s ? { dbId: parent, properties: s.properties } : null; });
|
||||
});
|
||||
const schemaKeys = $derived(new Set((schema ? schema.properties : []).map((p) => p.key)));
|
||||
// formula properties of the database, worked out for this record
|
||||
const formulas = $derived(schema && doc ? computeFormulas(schema.properties, { title: doc.title, props: doc.meta }) : {});
|
||||
async function addSchemaOption(p, name) {
|
||||
const s = await store.readSchema(schema.dbId);
|
||||
const q = s && s.properties.find((x) => x.key === p.key);
|
||||
if (!q || (q.options || []).includes(name)) return;
|
||||
(q.options ||= []).push(name);
|
||||
await store.saveSchema(schema.dbId, s);
|
||||
schema = { dbId: schema.dbId, properties: s.properties };
|
||||
}
|
||||
|
||||
let scrollerEl = $state();
|
||||
let titleEl = $state();
|
||||
let iconEl = $state();
|
||||
@@ -40,7 +66,7 @@
|
||||
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' });
|
||||
return isNaN(t) ? String(d) : t.toLocaleDateString(store.prefs.locale || undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
function restart(el, cls) {
|
||||
@@ -55,6 +81,37 @@
|
||||
restart(iconEl, 'spin');
|
||||
iconEl?.focus();
|
||||
}
|
||||
let coverBroken = $state(false);
|
||||
const coverImage = $derived(isImageCover(doc?.meta.cover) ? doc.meta.cover : null);
|
||||
$effect(() => { coverImage; coverBroken = false; });
|
||||
// Pick a picture with the file dialog, or take a dropped one: it is saved next to the page like any image.
|
||||
async function uploadCover() {
|
||||
try {
|
||||
const name = await api.pickImage(doc.id);
|
||||
if (name) setCover(name);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
// A picture from a web address: downloaded by the app and saved next to the page. Throws a message for the picker.
|
||||
async function coverFromUrl(address) {
|
||||
const name = await api.saveImageFromUrl(doc.id, address);
|
||||
if (name) setCover(name);
|
||||
}
|
||||
async function dropCover(e) {
|
||||
const file = [...(e.dataTransfer?.files || [])].find((f) => /^image\//.test(f.type));
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
coverOver = false;
|
||||
try {
|
||||
if (file.size > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
|
||||
const name = await api.saveImage(doc.id, file.name || 'cover.png', new Uint8Array(await file.arrayBuffer()));
|
||||
if (name) setCover(name);
|
||||
} catch (err) {
|
||||
toast(errorText(err), 'x');
|
||||
}
|
||||
}
|
||||
let coverOver = $state(false);
|
||||
function setCover(v) {
|
||||
pickingCover = false;
|
||||
store.setMeta({ cover: v });
|
||||
@@ -113,6 +170,52 @@
|
||||
let addingProp = $state(false);
|
||||
let newPropEl = $state();
|
||||
|
||||
/* A plain page's own properties are typed by their value: true/false is a checkbox, a number a number, YYYY-MM-DD a
|
||||
date, a list a multi-select, an address a link. Nothing but the value is stored, so Obsidian reads them as is. */
|
||||
const PLAIN_TYPES = ['text', 'number', 'date', 'checkbox', 'multiselect', 'url'];
|
||||
const HIDDEN_KEYS = ['icon', 'cover', 'tags', 'created', 'ink-colors', 'kanban-plugin', 'ink-database', 'source'];
|
||||
function inferType(v) {
|
||||
if (typeof v === 'boolean' || /^(true|false)$/i.test(String(v))) return 'checkbox';
|
||||
if (Array.isArray(v)) return 'multiselect';
|
||||
if (typeof v === 'number' || /^-?\d+(\.\d+)?$/.test(String(v).trim())) return 'number';
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(String(v))) return 'date';
|
||||
if (/^https?:\/\/\S+$/i.test(String(v).trim())) return 'url';
|
||||
return 'text';
|
||||
}
|
||||
const customKeys = $derived(doc ? Object.keys(doc.meta).filter((k) => doc.meta[k] !== undefined && !HIDDEN_KEYS.includes(k) && !schemaKeys.has(k)) : []);
|
||||
// the values other pages give this key, as the choices of a list property
|
||||
function optionsFor(k) {
|
||||
const seen = new Set();
|
||||
for (const p of store.pages) {
|
||||
const v = p.props && p.props[k];
|
||||
for (const x of Array.isArray(v) ? v : []) if (typeof x === 'string' && x.trim()) seen.add(x.trim());
|
||||
}
|
||||
const mine = doc.meta[k];
|
||||
for (const x of Array.isArray(mine) ? mine : []) seen.add(String(x));
|
||||
return [...seen].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
const propFor = (k) => {
|
||||
const type = inferType(doc.meta[k]);
|
||||
return { key: k, name: k, type, options: type === 'multiselect' ? optionsFor(k) : [] };
|
||||
};
|
||||
const DEFAULTS = { text: '', number: '0', date: () => new Date().toISOString().slice(0, 10), checkbox: 'false', multiselect: [], url: '' };
|
||||
const startValue = (t) => (typeof DEFAULTS[t] === 'function' ? DEFAULTS[t]() : DEFAULTS[t]);
|
||||
function convertValue(v, from, to) {
|
||||
if (to === 'multiselect') return Array.isArray(v) ? v : String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const plain = Array.isArray(v) ? v.join(', ') : String(v ?? '');
|
||||
if (to === 'checkbox') return /^(true|yes|1|x|on)$/i.test(plain) ? 'true' : 'false';
|
||||
if (to === 'number') { const n = Number(plain.replace(',', '.')); return Number.isFinite(n) && plain.trim() ? String(n) : '0'; }
|
||||
if (to === 'date') return /^\d{4}-\d{2}-\d{2}/.test(plain) ? plain.slice(0, 10) : startValue('date');
|
||||
return plain;
|
||||
}
|
||||
let typeMenu = $state(null); // { key, x, y } for changing a property's type
|
||||
let newType = $state('text');
|
||||
function setType(k, to) {
|
||||
const from = inferType(doc.meta[k]);
|
||||
typeMenu = null;
|
||||
if (from !== to) store.setMeta({ [k]: convertValue(doc.meta[k], from, to) });
|
||||
}
|
||||
|
||||
function updateMeta(k, v) {
|
||||
if (Array.isArray(doc.meta[k])) {
|
||||
store.setMeta({ [k]: v.split(',').map(s => s.trim()).filter(Boolean) });
|
||||
@@ -141,13 +244,15 @@
|
||||
|
||||
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]: '' });
|
||||
if (k && doc.meta[k] === undefined && !HIDDEN_KEYS.includes(k)) {
|
||||
store.setMeta({ [k]: startValue(newType) });
|
||||
}
|
||||
addingProp = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={(e) => { if (typeMenu && !e.target.closest?.('.ptype, .pk-type')) typeMenu = null; }} />
|
||||
|
||||
<div class="pagewrap">
|
||||
<div class="scroller" bind:this={scrollerEl}>
|
||||
{#if doc}
|
||||
@@ -157,12 +262,26 @@
|
||||
{/each}
|
||||
</datalist>
|
||||
{#key doc.version}
|
||||
<div class="cover enter" bind:this={coverEl} style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}">
|
||||
<div
|
||||
class="cover enter"
|
||||
class:photo={coverImage && !coverBroken}
|
||||
class:over={coverOver}
|
||||
bind:this={coverEl}
|
||||
style="--c1:{COVERS[cover][0]};--c2:{COVERS[cover][1]}"
|
||||
role="presentation"
|
||||
ondragover={(e) => { if ([...(e.dataTransfer?.items || [])].some((i) => i.kind === 'file')) { e.preventDefault(); coverOver = true; } }}
|
||||
ondragleave={() => (coverOver = false)}
|
||||
ondrop={dropCover}
|
||||
>
|
||||
{#if coverImage}
|
||||
<img class="cover-img" src={api.mediaUrl(doc.id, coverImage)} alt="" draggable="false" onerror={() => (coverBroken = true)} />
|
||||
{/if}
|
||||
<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}>
|
||||
<article class="page enter" class:wide={store.isBoard || store.isDatabase}>
|
||||
<div class="head">
|
||||
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}>
|
||||
<button class="p-icon" bind:this={iconEl} aria-label="Change icon" title="Change icon" aria-expanded={picking} onclick={() => (picking = !picking)}
|
||||
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: doc.id }; }}>
|
||||
<Icon name={icon} />
|
||||
</button>
|
||||
<h1
|
||||
@@ -176,12 +295,39 @@
|
||||
onblur={() => store.commitTitle(titleEl.textContent)}
|
||||
onpaste={onTitlePaste}
|
||||
></h1>
|
||||
{#if !store.isDatabase}
|
||||
<div class="props">
|
||||
{#if schema}
|
||||
{#each schema.properties as p (p.key)}
|
||||
<div class="prop">
|
||||
<span class="pk"><Icon name={typeInfo(p.type).icon} />{p.name}</span>
|
||||
<div class="pv db-pv">
|
||||
<PropCell
|
||||
prop={p}
|
||||
value={p.type === 'formula' ? formulas[p.key]?.text ?? '' : read(doc.meta[p.key], p.type)}
|
||||
error={p.type === 'formula' ? formulas[p.key]?.error || '' : ''}
|
||||
recordId={doc.id}
|
||||
choices={p.type === 'relation' ? store.records(schema.dbId).filter((r) => r.id !== doc.id).map((r) => r.title) : []}
|
||||
plain
|
||||
onchange={(v) => store.setMeta({ [p.key]: write(v, p.type) })}
|
||||
onaddoption={(n) => addSchemaOption(p, n)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<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}>
|
||||
<span
|
||||
class="chip in"
|
||||
class:tinted={!!tagHex(store.tagColors[t])}
|
||||
class:out={removing === t}
|
||||
style={tagHex(store.tagColors[t]) ? `--tc:${tagHex(store.tagColors[t])}` : undefined}
|
||||
role="presentation"
|
||||
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, tag: t }; }}
|
||||
>
|
||||
{t}<button class="chip-x" aria-label="Remove tag {t}" onclick={() => removeTag(t)}><Icon name="x" /></button>
|
||||
</span>
|
||||
{/each}
|
||||
@@ -212,44 +358,75 @@
|
||||
<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)}
|
||||
{#each customKeys as pk (pk)}
|
||||
{@const cp = propFor(pk)}
|
||||
<div class="prop">
|
||||
<span class="pk">
|
||||
<Icon name="list" />
|
||||
<button class="pk-type" aria-label="Change type of {pk}" title="Type: {typeInfo(cp.type).label}"
|
||||
onclick={(e) => { const r = e.currentTarget.getBoundingClientRect(); typeMenu = typeMenu && typeMenu.key === pk ? null : { key: pk, x: r.left, y: r.bottom + 4 }; }}>
|
||||
<Icon name={typeInfo(cp.type).icon} />
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
{#if cp.type === 'text'}
|
||||
<input class="pv custom-pv" value={doc.meta[pk]} onchange={(e) => updateMeta(pk, e.target.value)} placeholder="Empty" />
|
||||
{:else}
|
||||
<div class="pv db-pv">
|
||||
<PropCell
|
||||
prop={cp}
|
||||
value={read(doc.meta[pk], cp.type)}
|
||||
recordId={doc.id}
|
||||
plain
|
||||
onchange={(v) => store.setMeta({ [pk]: write(v, cp.type) })}
|
||||
onaddoption={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<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="pk"><Icon name={typeInfo(newType).icon} /><input class="custom-pk" bind:this={newPropEl} placeholder="{typeInfo(newType).label} property name" onkeydown={(e) => { if (e.key === 'Enter') commitNewProp(e.target.value); if (e.key === 'Escape') addingProp = false; }} onblur={(e) => { if (!e.relatedTarget?.classList?.contains('type-chip')) 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 class="pv tagv">
|
||||
{#if addingProp}
|
||||
{#each PLAIN_TYPES as t (t)}
|
||||
<button class="chip-add type-chip" class:on={newType === t} onclick={() => { newType = t; newPropEl?.focus(); }}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<button class="chip-add" onclick={() => { addingProp = true; setTimeout(() => newPropEl?.focus(), 0); }}><Icon name="plus" />Add property</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !store.isBoard}
|
||||
{#if !store.isBoard && !store.isDatabase}
|
||||
<Editor blocks={doc.blocks} onexittop={toTitleEnd} />
|
||||
<Backlinks id={doc.id} />
|
||||
{/if}
|
||||
</article>
|
||||
{#if typeMenu}
|
||||
<div class="ptype" role="menu" style="left:{typeMenu.x}px;top:{typeMenu.y}px">
|
||||
{#each PLAIN_TYPES as t (t)}
|
||||
<button class="mi" class:on={inferType(doc.meta[typeMenu.key]) === t} role="menuitem" onclick={() => setType(typeMenu.key, t)}><Icon name={typeInfo(t).icon} />{typeInfo(t).label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if pickingCover}
|
||||
<CoverPicker
|
||||
anchor={coverBtnEl}
|
||||
current={cover}
|
||||
current={coverImage ? '' : cover}
|
||||
hasImage={!!coverImage}
|
||||
onupload={uploadCover}
|
||||
onurl={coverFromUrl}
|
||||
onpick={setCover}
|
||||
onclose={() => { pickingCover = false; coverBtnEl?.focus(); }}
|
||||
/>
|
||||
@@ -263,7 +440,10 @@
|
||||
onclose={() => { picking = false; iconEl?.focus(); }}
|
||||
/>
|
||||
{/if}
|
||||
{#if store.isBoard}
|
||||
{#if store.isDatabase}
|
||||
<Database id={doc.id} body={doc.body} />
|
||||
<div class="page wide"><Backlinks id={doc.id} /></div>
|
||||
{:else if store.isBoard}
|
||||
<Board body={doc.body} />
|
||||
<div class="page wide"><Backlinks id={doc.id} /></div>
|
||||
{/if}
|
||||
@@ -275,5 +455,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if store.outlineOpen && doc && !store.isBoard}<Outline scroller={scrollerEl} />{/if}
|
||||
{#if store.outlineOpen && doc && !store.isBoard && !store.isDatabase}<Outline scroller={scrollerEl} />{/if}
|
||||
</div>
|
||||
Reference in new issue
Block a user