Files
familienarchiv/frontend/src/lib/shared/discussion/MentionEditor.svelte
2026-05-05 14:40:14 +02:00

202 lines
4.6 KiB
Svelte

<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectMention } from '$lib/shared/discussion/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type Props = {
value: string;
mentionCandidates: MentionDTO[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onsubmit?: () => void;
};
let {
value = $bindable(''),
mentionCandidates = $bindable([]),
placeholder = '',
rows = 3,
disabled = false,
onsubmit
}: Props = $props();
let query: string | null = $state(null);
let results: MentionDTO[] = $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;
return () => {
textarea = null;
};
}
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
// Calculate where the @ starts
const before = value.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
mentionStart = atIndex;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q) {
results = [];
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: MentionDTO[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
}
}, 200);
}
async function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
// Replace @partialQuery with @FirstName LastName (plus trailing space)
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
// Deduplicate and add to candidates
if (!mentionCandidates.some((c) => c.id === user.id)) {
mentionCandidates = [...mentionCandidates, user];
}
closePopup();
// Reposition cursor after the inserted mention
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) {
// Enter sends, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey && query === null) {
e.preventDefault();
onsubmit?.();
return;
}
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();
selectUser(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.mention_btn_label()}
>
{#if results.length === 0}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<div
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
tabindex="-1"
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
selectUser(user);
}}
>
{user.firstName}
{user.lastName}
</div>
{/each}
{/if}
</div>
{/if}
</div>