202 lines
4.6 KiB
Svelte
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>
|