Files
familienarchiv/frontend/src/lib/components/PersonMentionEditor.svelte
Marcel 793496440c refactor(person-mention): rename shadowed Paraglide m variable in dedup check
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>
2026-04-29 00:50:35 +02:00

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>