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:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent c0587cb267
commit 27fafa0bcd
39 files changed
+8744 -94

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
<script>
// Find in page (Ctrl+F). Matches are painted with the CSS Custom Highlight API,
// so the editable text itself is never touched.
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import { store } from '$lib/state.svelte.js';
let input = $state();
let q = $state('');
let count = $state(0);
let index = $state(0);
let ranges = [];
let timer = 0;
const hl = typeof CSS !== 'undefined' && CSS.highlights ? CSS.highlights : null;
const scroller = () => document.querySelector('.main .scroller');
// Every match as a Range. Each editable area is searched as one string, so a match may cross bold/italic.
function collect(query) {
const out = [];
const needle = query.toLowerCase();
if (!needle) return out;
for (const area of document.querySelectorAll('.main .page .title, .main .blocks .c, .main .blocks .c2, .main .blocks .tcell, .main .blocks .ecell, .main .board .kh-title, .main .board .kc-text')) {
const nodes = [];
let text = '';
const w = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
for (let n; (n = w.nextNode()); ) { nodes.push([n, text.length]); text += n.data; }
const hay = text.toLowerCase();
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + needle.length)) {
const r = document.createRange();
const place = (pos, end) => {
for (let i = nodes.length - 1; i >= 0; i--) {
const [n, start] = nodes[i];
if (pos > start || (pos === start && !end)) {
end ? r.setEnd(n, Math.min(pos - start, n.length)) : r.setStart(n, pos - start);
return;
}
}
end ? r.setEnd(nodes[0][0], 0) : r.setStart(nodes[0][0], 0);
};
place(at, false);
place(at + needle.length, true);
out.push(r);
}
}
return out;
}
function paint() {
if (!hl) return;
hl.set('find', new Highlight(...ranges));
if (ranges[index]) hl.set('find-current', new Highlight(ranges[index]));
else hl.delete('find-current');
}
function reveal() {
const r = ranges[index];
if (!r) return;
const el = r.startContainer.parentElement;
// A match inside a closed toggle: open it first.
const tg = el && el.closest('.tg:not(.open)');
if (tg) tg.querySelector('.tg-chev')?.click();
const sc = scroller();
if (!sc) return;
requestAnimationFrame(() => {
const box = r.getBoundingClientRect(), view = sc.getBoundingClientRect();
if (box.top < view.top + 40 || box.bottom > view.bottom - 40) {
sc.scrollBy({ top: box.top - view.top - view.height / 3, behavior: 'smooth' });
}
});
}
function run(keepPlace = false) {
const before = ranges[index];
ranges = collect(q);
count = ranges.length;
if (!keepPlace || !before) index = 0;
else {
// Stay on the same spot after an edit: first match at or after where we were.
const i = ranges.findIndex((r) => r.compareBoundaryPoints(Range.START_TO_START, before) >= 0);
index = i < 0 ? 0 : i;
}
paint();
if (!keepPlace) reveal();
}
function step(dir) {
if (!count) return;
index = (index + dir + count) % count;
paint();
reveal();
}
function close(selectMatch) {
const r = ranges[index];
hl?.delete('find');
hl?.delete('find-current');
store.findOpen = 0;
if (selectMatch && r && r.startContainer.isConnected) {
const area = r.startContainer.parentElement?.closest('[contenteditable="true"]');
area?.focus();
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
}
}
function onkeydown(e) {
if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); }
}
// Ctrl+F again (store.findOpen bumps): back to the input with the text selected.
$effect(() => {
store.findOpen;
tick().then(() => { input?.focus(); input?.select(); });
});
// New page: search it from the top.
$effect(() => {
store.doc?.version;
clearTimeout(timer);
timer = setTimeout(() => run(false), 120);
});
onMount(() => {
// Typing in the page moves matches around: re-search shortly after.
const onEdit = (e) => {
if (e.target === input) return;
clearTimeout(timer);
timer = setTimeout(() => run(true), 150);
};
document.addEventListener('input', onEdit, true);
return () => {
document.removeEventListener('input', onEdit, true);
clearTimeout(timer);
hl?.delete('find');
hl?.delete('find-current');
};
});
</script>
<div class="findbar pop" role="search">
<Icon name="search" />
<input
bind:this={input}
bind:value={q}
placeholder="Find in page"
aria-label="Find in page"
spellcheck="false"
oninput={() => run(false)}
{onkeydown}
/>
<span class="find-n">{q ? (count ? `${index + 1} of ${count}` : 'No results') : ''}</span>
<button class="btn-icon" aria-label="Previous match" title="Previous (Shift+Enter)" disabled={!count} onclick={() => step(-1)}>
<Icon name="chev" class="up" />
</button>
<button class="btn-icon" aria-label="Next match" title="Next (Enter)" disabled={!count} onclick={() => step(1)}>
<Icon name="chev" class="down" />
</button>
<button class="btn-icon" aria-label="Close" title="Close (Esc)" onclick={() => close(false)}><Icon name="x" /></button>
</div>