Files
ink-note-app/src/lib/components/FindBar.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

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>