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>
162 lines
5.4 KiB
Svelte
162 lines
5.4 KiB
Svelte
<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>
|