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>
460 lines
19 KiB
Svelte
460 lines
19 KiB
Svelte
<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, isImageCover } from '$lib/icons.js';
|
|
import { tagHex } from '$lib/tagColors.js';
|
|
import IconPicker from './IconPicker.svelte';
|
|
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);
|
|
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);
|
|
|
|
// 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();
|
|
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(store.prefs.locale || 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();
|
|
}
|
|
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 });
|
|
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();
|
|
|
|
/* 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) });
|
|
} 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] === 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}
|
|
<datalist id="all-tags">
|
|
{#each store.tagCounts as [tag]}
|
|
<option value={tag}></option>
|
|
{/each}
|
|
</datalist>
|
|
{#key doc.version}
|
|
<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 || 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)}
|
|
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: doc.id }; }}>
|
|
<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>
|
|
{#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: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}
|
|
{#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 customKeys as pk (pk)}
|
|
{@const cp = propFor(pk)}
|
|
<div class="prop">
|
|
<span class="pk">
|
|
<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%;">
|
|
{#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={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 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 && !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={coverImage ? '' : cover}
|
|
hasImage={!!coverImage}
|
|
onupload={uploadCover}
|
|
onurl={coverFromUrl}
|
|
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.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}
|
|
{/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 && !store.isDatabase}<Outline scroller={scrollerEl} />{/if}
|
|
</div>
|