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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user