The <input maxlength=100> attribute capped direct user edits but did not cover the Tiptap editor-mirror path. A 5000-char @-suffix in the contenteditable would mirror unchanged into searchQuery and reach runSearch. Clipping at the mirror keeps both paths bounded. The literal in the maxlength attribute is also bound to the new MAX_QUERY_LENGTH constant so the two stay in sync. Server-side cap tracked separately. Nora #1 on PR #629. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
252 lines
8.9 KiB
Svelte
252 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
import type { components } from '$lib/generated/api';
|
|
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
|
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
|
import { untrack } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
|
|
// Layered defence cap on the @mention search query length (CWE-400
|
|
// amplification). The <input maxlength> attribute below caps direct
|
|
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
|
|
// $effect -> searchQuery) is not covered by `maxlength` since the
|
|
// contenteditable has no such enforcement. Clipping at the mirror keeps
|
|
// the cap honest from both paths. Tracked server-side separately.
|
|
// Nora #1 on PR #629.
|
|
const MAX_QUERY_LENGTH = 100;
|
|
|
|
// The dropdown receives a single reactive state object. PersonMentionEditor
|
|
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
|
|
// proxy reactivity propagates the change here. This is the supported way to
|
|
// update an imperatively-mounted Svelte 5 component — `mount` does not return
|
|
// settable prop accessors.
|
|
type DropdownState = {
|
|
items: Person[];
|
|
command: (item: Person) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
};
|
|
|
|
let {
|
|
model,
|
|
editorQuery = '',
|
|
onSearch = () => {}
|
|
}: {
|
|
model: DropdownState;
|
|
/** Text typed after `@` in the host editor. Mirrors into the search input
|
|
* until the user takes manual ownership by typing into the input itself. */
|
|
editorQuery?: string;
|
|
onSearch?: (query: string) => void;
|
|
} = $props();
|
|
|
|
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
|
let userHasEdited = $state(false);
|
|
|
|
// Mirror the editor's typed text until the user takes ownership.
|
|
//
|
|
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
|
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
|
|
// A `$derived` would be read-only and would clobber direct user edits on
|
|
// every editor keystroke. The `userHasEdited` latch pins ownership once the
|
|
// user types into the input. Felix #1 on PR #629.
|
|
$effect(() => {
|
|
if (!userHasEdited) {
|
|
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
|
|
}
|
|
});
|
|
|
|
// Fire onSearch whenever the effective query changes — covers both the
|
|
// editor mirror and direct input edits. This is the only place onSearch
|
|
// fires; when the dropdown is unmounted, the effect is disposed and no
|
|
// further fetches occur.
|
|
$effect(() => {
|
|
onSearch(searchQuery);
|
|
});
|
|
|
|
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
|
// reset when `items` changes (so it never points past the end of a new list).
|
|
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
|
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
|
// apply: the mutation in onKeyDown is not a derivation.
|
|
let highlightedIndex = $state(0);
|
|
|
|
$effect(() => {
|
|
// Read model.items to subscribe; reset index whenever the list is replaced.
|
|
void model.items;
|
|
highlightedIndex = 0;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Positioning — flip strategy: open upward when there is not enough room
|
|
// below the cursor to show the dropdown without clipping the viewport.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Position = {
|
|
top: string | null;
|
|
bottom: string | null;
|
|
left: string;
|
|
};
|
|
|
|
const DROPDOWN_CLEARANCE_PX = 200;
|
|
|
|
const position = $derived.by<Position>(() => {
|
|
const cr = model.clientRect;
|
|
if (!cr) return { top: '0px', bottom: null, left: '0px' };
|
|
const rect = cr();
|
|
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
|
|
|
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
|
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
|
|
|
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
|
// Not enough space below — anchor bottom of dropdown to top of caret.
|
|
return {
|
|
top: null,
|
|
bottom: `${window.innerHeight - rect.top}px`,
|
|
left
|
|
};
|
|
}
|
|
|
|
return { top: `${rect.bottom}px`, bottom: null, left };
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyboard handler — exported so Tiptap's render() can forward events.
|
|
// Returns true when the event is consumed (prevents the editor's default).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function onKeyDown(event: KeyboardEvent): boolean {
|
|
const len = model.items.length;
|
|
if (event.key === 'ArrowDown') {
|
|
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
const selected = model.items[highlightedIndex];
|
|
if (selected) {
|
|
model.command(selected);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
|
return false;
|
|
}
|
|
|
|
function selectItem(item: Person) {
|
|
model.command(item);
|
|
}
|
|
</script>
|
|
|
|
<!--
|
|
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
|
Positioned absolutely relative to the viewport using inline styles derived
|
|
from the Tiptap clientRect() callback.
|
|
|
|
SECURITY: This component receives pre-filtered Person[] items from the
|
|
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
|
proxy injecting the auth_token cookie as the Authorization header.
|
|
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
|
unauthenticated users.
|
|
-->
|
|
<div
|
|
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
|
role="listbox"
|
|
aria-label={m.person_mention_btn_label()}
|
|
style:top={position.top}
|
|
style:bottom={position.bottom}
|
|
style:left={position.left}
|
|
>
|
|
<div class="border-b border-line px-3 py-2">
|
|
<label class="sr-only" for="mention-search">{m.person_mention_btn_label()}</label>
|
|
<div class="flex items-center gap-2">
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
class="h-5 w-5 shrink-0 text-ink-2"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
|
</svg>
|
|
<input
|
|
id="mention-search"
|
|
type="search"
|
|
data-test-search-input
|
|
maxlength={MAX_QUERY_LENGTH}
|
|
class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
|
placeholder={m.person_mention_search_prompt()}
|
|
bind:value={searchQuery}
|
|
oninput={() => {
|
|
userHasEdited = true;
|
|
}}
|
|
onmousedown={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{#if model.items.length === 0}
|
|
<!--
|
|
Single live region so screen readers announce the transition between
|
|
"Namen eingeben…" (empty search) and "Keine Personen gefunden"
|
|
(searched but empty). Leonie FINDING-MENTION-002 on PR #629.
|
|
-->
|
|
<p class="px-3 py-2.5 font-sans text-sm text-ink-3" aria-live="polite">
|
|
{searchQuery.trim() === ''
|
|
? m.person_mention_search_prompt()
|
|
: m.person_mention_popup_empty()}
|
|
</p>
|
|
<!--
|
|
Empty-state escape hatch — without it the transcriber has to close
|
|
the dropdown, navigate to /persons/new, come back, and re-type the
|
|
query. target=_blank keeps the document and editor state intact;
|
|
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
|
-->
|
|
<a
|
|
href="/persons/new"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
|
onmousedown={(e) => e.preventDefault()}
|
|
>
|
|
{m.person_mention_create_new()}
|
|
<span aria-hidden="true">→</span>
|
|
</a>
|
|
{:else}
|
|
{#each model.items 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',
|
|
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
|
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
|
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
|
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
|
]}
|
|
role="option"
|
|
aria-selected={i === highlightedIndex}
|
|
data-test-person-id={person.id}
|
|
tabindex="-1"
|
|
onmousedown={(e) => {
|
|
// Prevent blur on the editor before the selection fires.
|
|
e.preventDefault();
|
|
selectItem(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>
|