feat(PersonMentionEditor): rewrite as Tiptap editor with AC-1 typed-text displayName

Replaces the textarea-based editor with a Tiptap v3 contenteditable.
The custom Mention node uses personId/displayName attrs (instead of
Tiptap's default id/label) so mentionSerializer round-trips cleanly.

AC-1 fix (issue #372): when the user types '@Aug' and selects
'Auguste Raddatz', the mention node stores displayName: 'Aug' (the
typed query) — not the person's DB display name. This preserves
archival fidelity of the original transcription.

The MentionDropdown is mounted imperatively on document.body via
Svelte 5's mount(). Its three pieces of dynamic state (items,
command, clientRect) are passed as a single $state proxy (model)
because Svelte 5's mount() does not return prop accessors.

Spec is fully rewritten — all old tests used document.querySelector
('textarea') which is dead after the migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 15:53:21 +02:00
parent 39ddf90725
commit d87ad36278
2 changed files with 344 additions and 450 deletions

View File

@@ -1,263 +1,242 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectPersonMention } from '$lib/utils/personMention';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { onMount, onDestroy, mount, unmount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Mention } from '@tiptap/extension-mention';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/types';
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
import MentionDropdown from './MentionDropdown.svelte';
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);
onSelectionChange?: (text: string | null) => void;
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
rows = 1,
disabled = false,
onfocus,
onblur,
captureTextarea
onSelectionChange
}: Props = $props();
let query: string | null = $state(null);
let results: Person[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let loading = $state(false);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
resizeTextarea();
const parentCleanup = captureTextarea?.(node);
return () => {
parentCleanup?.();
textarea = null;
};
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
// Autoresize on every value change — read `value` so this $effect
// re-runs whenever the bound prop is reassigned.
$effect(() => {
void value;
resizeTextarea();
// Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy —
// this is required because Svelte 5's `mount()` does NOT return prop
// accessors; setting `instance.items = ...` does not update the component.
let dropdownState = $state<{
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
}>({
items: [],
command: () => {},
clientRect: null
});
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectPersonMention(value, cursorPos);
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
if (detected === null) {
closePopup();
return;
}
onMount(() => {
// Custom Mention node: uses personId / displayName instead of the
// default id / label attribute names so the mentionSerializer can
// round-trip correctly without attribute remapping.
const CustomMention = Mention.extend({
addAttributes() {
return {
personId: {
default: null,
parseHTML: (el) => el.getAttribute('data-person-id'),
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
},
displayName: {
default: null,
parseHTML: (el) => el.getAttribute('data-display-name'),
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
},
mentionSuggestionChar: {
default: '@',
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
renderHTML: (attrs) => ({
'data-mention-suggestion-char': attrs.mentionSuggestionChar
})
}
};
}
});
const before = value.slice(0, cursorPos);
mentionStart = before.lastIndexOf('@');
editor = new Editor({
element: editorEl,
extensions: [
StarterKit.configure({
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
hardBreak: false,
horizontalRule: false
}),
CustomMention.configure({
renderHTML({ node }) {
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-brand-mint underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
items: async ({ query }: { query: string }) => {
if (!query) return [];
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
if (!res.ok) return [];
return ((await res.json()) as Person[]).slice(0, 5);
} catch {
return [];
}
},
// AC-1 fix: insert the typed query as displayName, not person.displayName.
command({ editor: ed, range, props }) {
const p = props as unknown as { personId: string; displayName: string };
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
ed.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { personId: p.personId, displayName: p.displayName }
},
{ type: 'text', text: ' ' }
])
.run();
},
render() {
let component: object | null = null;
let exports: DropdownExports | null = null;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
// Tiptap's SuggestionProps types `command` against the default
// MentionNodeAttrs (id/label). Our custom Mention extension uses
// personId/displayName, so we cast the renderProps locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
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 = [];
loading = false;
return;
}
loading = true;
debounceTimer = setTimeout(async () => {
try {
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
// cookie as the Authorization header (vite.config.ts) and on the
// browser's same-origin policy for the /api/* path. Mounted in
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
// users.
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 = [];
const updateState = (renderProps: LooseRenderProps) => {
dropdownState.items = renderProps.items as Person[];
// AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) =>
renderProps.command({
personId: item.id,
displayName: renderProps.query
});
dropdownState.clientRect = renderProps.clientRect ?? null;
};
return {
onStart(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
const mounted = mount(MentionDropdown, {
target: document.body,
props: { model: dropdownState }
});
component = mounted as object;
exports = mounted as unknown as DropdownExports;
},
onUpdate(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return exports?.onKeyDown(event) ?? false;
},
onExit() {
if (component) {
unmount(component);
component = null;
exports = null;
}
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
...(placeholder ? { 'data-placeholder': placeholder } : {}),
class: [
'min-h-[120px] px-1 py-2.5',
'font-serif text-base leading-relaxed text-ink',
'focus:outline-none',
'tiptap-editor-inner'
].join(' ')
}
} catch {
results = [];
} finally {
loading = false;
},
onUpdate({ editor: ed }) {
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
value = text;
mentionedPersons = mp;
},
onFocus() {
onfocus?.();
},
onBlur() {
onblur?.();
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
}
}, 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;
loading = false;
clearTimeout(debounceTimer);
}
function handleBlur() {
// Small delay so an option's onmousedown can fire and select before the
// popup unmounts. Without this, clicking a result on the way out would
// race with blur and lose the selection.
setTimeout(() => closePopup(), 150);
onblur?.();
}
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);
onDestroy(() => {
editor?.destroy();
});
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={onfocus}
onblur={handleBlur}
></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 loading}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
{:else if results.length === 0}
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
<a
href="/persons/new?name={encodeURIComponent(query ?? '')}"
target="_blank"
rel="noopener"
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
>
{m.person_mention_create_new()}
</a>
</div>
{: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',
// Keyboard-highlighted row gets a stronger token than hover so
// keyboard users (and tablet stylus users sweeping over rows)
// can tell the cursor position apart from a hover (Leonie #5507 §3,
// WCAG 1.4.11 Non-Text Contrast).
i === highlightedIndex &&
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
]}
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>
<div
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
class:opacity-50={disabled}
class:pointer-events-none={disabled}
bind:this={editorEl}
></div>