Files
ink-note-app/src/lib/components/PageView.svelte
T
Your NameandClaude Sonnet 5.5 27fafa0bcd 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>
2026-10-15 11:57:09 +02:00

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>