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
@@ -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>
|
||||
Reference in new issue
Block a user