139 lines
3.7 KiB
Svelte
139 lines
3.7 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { createEventDispatcher, tick } from 'svelte';
|
||
|
|
import { noteTitle, noteBody, highlightRegex } from '../search';
|
||
|
|
import type { Note } from '../types';
|
||
|
|
|
||
|
|
export let filtered: Note[] = [];
|
||
|
|
export let selectedId: string | null = null;
|
||
|
|
export let query = '';
|
||
|
|
|
||
|
|
// DOM cap: filtering is in-memory over everything, but rendering thousands of
|
||
|
|
// rows would blow the <10 ms keystroke budget. Selection stays within the cap.
|
||
|
|
const MAX_ROWS = 100;
|
||
|
|
|
||
|
|
const dispatch = createEventDispatcher<{ open: string }>();
|
||
|
|
|
||
|
|
let listEl: HTMLElement;
|
||
|
|
|
||
|
|
$: visible = filtered.length > MAX_ROWS ? filtered.slice(0, MAX_ROWS) : filtered;
|
||
|
|
$: rx = highlightRegex(query);
|
||
|
|
|
||
|
|
$: if (selectedId) {
|
||
|
|
void tick().then(() => {
|
||
|
|
listEl?.querySelector('.row.selected')?.scrollIntoView({ block: 'nearest' });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function esc(s: string): string {
|
||
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
|
}
|
||
|
|
|
||
|
|
function hl(s: string): string {
|
||
|
|
if (!rx) return esc(s);
|
||
|
|
rx.lastIndex = 0;
|
||
|
|
let out = '';
|
||
|
|
let last = 0;
|
||
|
|
let m: RegExpExecArray | null;
|
||
|
|
while ((m = rx.exec(s))) {
|
||
|
|
out += esc(s.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
|
||
|
|
last = m.index + m[0].length;
|
||
|
|
if (m.index === rx.lastIndex) rx.lastIndex++;
|
||
|
|
}
|
||
|
|
return out + esc(s.slice(last));
|
||
|
|
}
|
||
|
|
|
||
|
|
function when(ms: number): string {
|
||
|
|
const d = new Date(ms);
|
||
|
|
const now = new Date();
|
||
|
|
if (d.toDateString() === now.toDateString())
|
||
|
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||
|
|
if (d.getFullYear() === now.getFullYear())
|
||
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<div class="list" bind:this={listEl} role="listbox" aria-label="Notes">
|
||
|
|
{#each visible as n (n.id)}
|
||
|
|
<div
|
||
|
|
class="row"
|
||
|
|
class:selected={n.id === selectedId}
|
||
|
|
role="option"
|
||
|
|
aria-selected={n.id === selectedId}
|
||
|
|
tabindex="-1"
|
||
|
|
on:click={() => dispatch('open', n.id)}
|
||
|
|
on:keydown={(e) => e.key === 'Enter' && dispatch('open', n.id)}
|
||
|
|
>
|
||
|
|
<span class="title">{@html hl(noteTitle(n.content))}</span>
|
||
|
|
<span class="snippet">{@html hl(noteBody(n.content).slice(0, 120))}</span>
|
||
|
|
<span class="meta">
|
||
|
|
{#each n.tags as t}<span class="tag">{t}</span>{/each}
|
||
|
|
<span class="date">{when(n.modified_at)}</span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="none">{query.trim() ? 'No matches — Enter creates this note' : 'No notes yet'}</div>
|
||
|
|
{/each}
|
||
|
|
{#if filtered.length > MAX_ROWS}
|
||
|
|
<div class="none">…and {filtered.length - MAX_ROWS} more — keep typing to narrow down</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.list {
|
||
|
|
min-height: 100%;
|
||
|
|
}
|
||
|
|
.row {
|
||
|
|
display: flex;
|
||
|
|
align-items: baseline;
|
||
|
|
gap: 10px;
|
||
|
|
padding: 6px 12px;
|
||
|
|
line-height: 1.7;
|
||
|
|
border-bottom: 1px solid var(--sep);
|
||
|
|
cursor: default;
|
||
|
|
white-space: nowrap;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
.row.selected {
|
||
|
|
background: var(--sel-bg);
|
||
|
|
}
|
||
|
|
.title {
|
||
|
|
font-weight: 600;
|
||
|
|
flex-shrink: 0;
|
||
|
|
max-width: 55%;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
.snippet {
|
||
|
|
flex: 1;
|
||
|
|
color: var(--fg-dim);
|
||
|
|
font-size: 12.5px;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
}
|
||
|
|
.meta {
|
||
|
|
margin-left: auto;
|
||
|
|
flex-shrink: 0;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
}
|
||
|
|
.tag {
|
||
|
|
background: var(--accent-bg);
|
||
|
|
color: var(--accent);
|
||
|
|
font-size: 11px;
|
||
|
|
padding: 1px 7px;
|
||
|
|
border-radius: 9px;
|
||
|
|
}
|
||
|
|
.date {
|
||
|
|
color: var(--fg-dim);
|
||
|
|
font-size: 12px;
|
||
|
|
font-variant-numeric: tabular-nums;
|
||
|
|
}
|
||
|
|
.none {
|
||
|
|
padding: 16px;
|
||
|
|
color: var(--fg-dim);
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
</style>
|