Felix #1: inside selectPerson the .some((m) => ...) parameter shadowed the imported Paraglide m helper. Functionally fine, but a footgun. Rename to existing for clarity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
5.3 KiB
Svelte
214 lines
5.3 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, tick } from 'svelte';
|
|
import { detectPersonMention } from '$lib/utils/personMention';
|
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import type { components } from '$lib/generated/api';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
type PersonMention = components['schemas']['PersonMention'];
|
|
|
|
type Props = {
|
|
value: string;
|
|
mentionedPersons: PersonMention[];
|
|
placeholder?: string;
|
|
rows?: number;
|
|
disabled?: boolean;
|
|
onfocus?: () => void;
|
|
onblur?: () => void;
|
|
// Optional escape hatch: lets the parent observe the underlying textarea node
|
|
// (e.g. to read selection bounds for quote-selection features). Returning a
|
|
// cleanup function from the parent is not required.
|
|
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
|
|
};
|
|
|
|
let {
|
|
value = $bindable(''),
|
|
mentionedPersons = $bindable([]),
|
|
placeholder = '',
|
|
rows = 1,
|
|
disabled = false,
|
|
onfocus,
|
|
onblur,
|
|
captureTextarea
|
|
}: Props = $props();
|
|
|
|
let query: string | null = $state(null);
|
|
let results: Person[] = $state([]);
|
|
let highlightedIndex = $state(0);
|
|
let mentionStart = $state(0);
|
|
|
|
let textarea: HTMLTextAreaElement | null = null;
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
function attachTextarea(node: HTMLTextAreaElement) {
|
|
textarea = node;
|
|
const parentCleanup = captureTextarea?.(node);
|
|
return () => {
|
|
parentCleanup?.();
|
|
textarea = null;
|
|
};
|
|
}
|
|
|
|
function handleInput() {
|
|
if (!textarea) return;
|
|
const cursorPos = textarea.selectionStart;
|
|
const detected = detectPersonMention(value, cursorPos);
|
|
|
|
if (detected === null) {
|
|
closePopup();
|
|
return;
|
|
}
|
|
|
|
const before = value.slice(0, cursorPos);
|
|
mentionStart = before.lastIndexOf('@');
|
|
|
|
if (query !== detected) {
|
|
query = detected;
|
|
highlightedIndex = 0;
|
|
scheduleSearch(detected);
|
|
}
|
|
}
|
|
|
|
function scheduleSearch(q: string) {
|
|
clearTimeout(debounceTimer);
|
|
if (!q.trim()) {
|
|
// Empty query: keep popup open with last results so the user can browse,
|
|
// but don't fire a backend call until they actually type something.
|
|
results = [];
|
|
return;
|
|
}
|
|
debounceTimer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
|
|
if (res.ok) {
|
|
const data: Person[] = await res.json();
|
|
results = data.slice(0, 5);
|
|
} else {
|
|
results = [];
|
|
}
|
|
} catch {
|
|
results = [];
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
async function selectPerson(person: Person) {
|
|
if (!textarea) return;
|
|
|
|
const displayName = person.displayName ?? '';
|
|
const replacement = `@${displayName} `;
|
|
const cursorPos = textarea.selectionStart;
|
|
const before = value.slice(0, mentionStart);
|
|
const after = value.slice(cursorPos);
|
|
value = before + replacement + after;
|
|
|
|
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
|
|
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
|
|
}
|
|
|
|
closePopup();
|
|
|
|
await tick();
|
|
if (!textarea) return;
|
|
const pos = mentionStart + replacement.length;
|
|
textarea.selectionStart = pos;
|
|
textarea.selectionEnd = pos;
|
|
textarea.focus();
|
|
}
|
|
|
|
function closePopup() {
|
|
query = null;
|
|
results = [];
|
|
highlightedIndex = 0;
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (query === null) return;
|
|
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closePopup();
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (results.length > 0) {
|
|
highlightedIndex = (highlightedIndex + 1) % results.length;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (results.length > 0) {
|
|
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && results.length > 0) {
|
|
e.preventDefault();
|
|
selectPerson(results[highlightedIndex]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
onDestroy(() => clearTimeout(debounceTimer));
|
|
|
|
const popupOpen = $derived(query !== null);
|
|
</script>
|
|
|
|
<div class="relative">
|
|
<textarea
|
|
{@attach attachTextarea}
|
|
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
|
rows={rows}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
bind:value={value}
|
|
oninput={handleInput}
|
|
onkeydown={handleKeydown}
|
|
onfocus={onfocus}
|
|
onblur={onblur}
|
|
></textarea>
|
|
|
|
{#if popupOpen}
|
|
<div
|
|
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
|
role="listbox"
|
|
aria-label={m.person_mention_btn_label()}
|
|
>
|
|
{#if results.length === 0}
|
|
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
|
|
{:else}
|
|
{#each results as person, i (person.id)}
|
|
<div
|
|
class={[
|
|
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
|
i === highlightedIndex && 'bg-canvas'
|
|
]}
|
|
role="option"
|
|
aria-selected={i === highlightedIndex}
|
|
data-test-person-id={person.id}
|
|
tabindex="-1"
|
|
onmousedown={(e) => {
|
|
e.preventDefault();
|
|
selectPerson(person);
|
|
}}
|
|
>
|
|
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
|
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
<span class="truncate font-sans text-xs text-ink-3">
|
|
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|