Files
familienarchiv/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte
Marcel 9bba5e4a7a feat(transcription): announce re-edit context via the existing live region (#628)
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00

474 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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/shared/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import { debounce } from '$lib/shared/utils/debounce';
import MentionDropdown from './MentionDropdown.svelte';
import { createMentionNodeView } from './mentionNodeView';
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
type Person = components['schemas']['Person'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
onSelectionChange?: (text: string | null) => void;
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
disabled = false,
onfocus,
onblur,
onSelectionChange
}: Props = $props();
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is
// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when
// the host component is unmounted while the dropdown is still open).
let mountedDropdown: object | null = null;
// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing
// debounced search can fire after the editor is gone and pollute later tests.
let cancelPendingSearch: (() => void) | null = null;
// 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;
editorQuery: string;
}>({
items: [],
command: () => {},
clientRect: null,
editorQuery: ''
});
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
type CommitFn = (item: Person) => void;
type RectGetter = (() => DOMRect | null) | null;
// Tiptap's SuggestionProps types `command` against the default MentionNodeAttrs
// (id/label). Our custom Mention extension uses personId/displayName, so the
// render() adapter casts the renderProps to this looser shape locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
// Single owner of the dropdown lifecycle. Both entry points route through one
// controller — Tiptap's fresh-`@` suggestion (via the render() adapter below)
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever
// mounted (the AC-6 single-dropdown invariant). open() closes any prior
// dropdown first; render() is a thin adapter over open()/update()/close().
type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string };
type MentionController = {
open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
update: (clientRect: RectGetter, query: string, commit: CommitFn) => void;
close: () => void;
onKeyDown: (event: KeyboardEvent) => boolean;
};
function createMentionController(): MentionController {
// Request-token guard: every search AND every open bumps `requestId`;
// runSearch captures the id active when its fetch starts and discards the
// response if a newer search/open has happened since. Without this, a late
// response can repopulate a dropdown the user already moved on from (e.g.
// open A → open B → A's stale response). Sara on PR #629.
let requestId = 0;
let exports: DropdownExports | null = null;
const runSearch = async (query: string) => {
const id = requestId;
try {
// Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629.
const res = await fetch(
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
);
if (id !== requestId) return;
if (!res.ok) {
dropdownState.items = [];
return;
}
const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return;
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch {
if (id !== requestId) return;
dropdownState.items = [];
}
};
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
// Hoisted so onDestroy can cancel any pending fetch even after the editor is
// gone — a trailing debounced search would otherwise pollute later tests.
cancelPendingSearch = () => debouncedSearch.cancel();
const onSearch = (query: string) => {
requestId++;
if (query.trim() === '') {
debouncedSearch.cancel();
dropdownState.items = [];
return;
}
debouncedSearch(query);
};
const close = () => {
debouncedSearch.cancel();
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
exports = null;
}
};
// Clip the query once so the dropdown's editor-mirror sees the capped value
// (CWE-400 amplification — Nora #1 / Felix #3 on PR #629). The commit closure
// is supplied by the caller, so the fresh-insert path keeps clipping the
// inserted displayName while the relink path (#628) preserves the stored
// displayName by construction.
const writeState = (clientRect: RectGetter, query: string, commit: CommitFn) => {
dropdownState.command = commit;
dropdownState.clientRect = clientRect;
dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH);
};
// Close the dropdown without touching the document and return focus to the
// editor — wired to the dropdown's × control and the re-edit Escape path.
const dismiss = () => {
close();
editor?.commands.focus();
};
const open = (
clientRect: RectGetter,
query: string,
commit: CommitFn,
opts: OpenOptions = {}
) => {
// Single-dropdown invariant: tear down any open dropdown before mounting a
// new one, and bump the request token so a previous open's in-flight fetch
// cannot repopulate this dropdown.
close();
requestId++;
dropdownState.items = [];
writeState(clientRect, query, commit);
const mounted = mount(MentionDropdown, {
target: document.body,
props: {
model: dropdownState,
ondismiss: dismiss,
focusOnMount: opts.focusOnMount ?? false,
editingDisplayName: opts.editingDisplayName ?? '',
// MentionDropdown reads `editorQuery` off the shared state proxy via
// this getter — Svelte 5's mount() does not expose settable prop
// accessors, so we route through the proxy (same pattern as items).
get editorQuery() {
return dropdownState.editorQuery;
},
onSearch
}
});
mountedDropdown = mounted as object;
exports = mounted as unknown as DropdownExports;
};
const update = (clientRect: RectGetter, query: string, commit: CommitFn) => {
writeState(clientRect, query, commit);
};
const onKeyDown = (event: KeyboardEvent) => exports?.onKeyDown(event) ?? false;
return { open, update, close, onKeyDown };
}
const controller = createMentionController();
// Re-link an existing mention in place (#628 AC-3/AC-5). Captures the node's
// pos when the pencil opens and swaps ONLY personId via setNodeMarkup, so the
// stored displayName is preserved by construction — never round-tripped through
// insertContentAt (which would rebind displayName to the search query). The new
// personId comes solely from the selected Person (anti-spoof — never from the
// reflected data-person-id or the search input).
function commitRelink(pos: number): CommitFn {
return (item: Person) => {
if (!editor) return;
editor
.chain()
.focus()
.command(({ tr, state }) => {
const node = state.doc.nodeAt(pos);
if (!node || node.type.name !== 'mention') return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId: item.id });
return true;
})
.run();
controller.close();
};
}
// Entry point handed to the mention NodeView's pencil. Opens the one dropdown
// (closing any other first — AC-6) anchored at the mention and pre-filled with
// the stored displayName.
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
if (!editor || !editor.isEditable) return;
controller.open(getRect, displayName, commitRelink(pos), {
focusOnMount: true,
editingDisplayName: displayName
});
}
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
})
}
};
},
// #628: host the mention as a NodeView so each token carries a pencil
// re-edit affordance. renderHTML/renderText below stay for serialization
// and clipboard; this only governs the live in-editor DOM.
addNodeView() {
return createMentionNodeView(requestRelink);
}
});
editor = new Editor({
element: editorEl,
// Initial editable state honors the `disabled` prop. The reactive
// $effect below keeps it in sync if the prop flips after mount —
// without this, a keyboard user can tab into the contenteditable
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
editable: !disabled,
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 }) {
// Underline color matches the read-mode .person-mention rule
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
allowSpaces: true,
// ─────────────────────────────────────────────────────────────
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
// Tiptap's suggestion plugin lives entirely on the client and
// fires on every keystroke after `@`. Routing each query through
// a SvelteKit form action would round-trip through SSR for a
// dropdown that needs to feel instantaneous, and a +server.ts
// endpoint would only proxy the same call. Auth flows through
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
// network surface is identical to a server-driven call.
// Markus #5616: an ADR will formalise this. Open follow-up:
// "ADR: client-side fetch exception for editor suggestion plugins."
// Nora #5618 #3 — separate issue tracks the GET /api/persons
// response-shape audit (PersonSummaryDTO leaks `notes`).
// ─────────────────────────────────────────────────────────────
// Tiptap's suggestion plugin requires an `items()` callback to keep
// the dropdown alive, but the actual fetch is owned by `runSearch`
// below — routed through the dropdown's search input via the
// debounced `onSearch` channel. Returning `[]` here keeps Tiptap
// happy without firing a duplicate per-keystroke fetch.
// Markus #5616 / Felix / Nora / Sara on PR #629.
items: async () => [],
// 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();
},
// Thin adapter over the single mention controller. The fresh-`@`
// commit clips the typed query into the inserted displayName
// (AC-1, #380); the controller owns the dropdown lifecycle and the
// debounced/request-token search.
render() {
const buildFreshCommit = (loose: LooseRenderProps): CommitFn => {
const clippedQuery = loose.query.slice(0, MAX_QUERY_LENGTH);
return (item: Person) =>
loose.command({ personId: item.id, displayName: clippedQuery });
};
return {
onStart(renderProps) {
const loose = renderProps as unknown as LooseRenderProps;
controller.open(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
},
onUpdate(renderProps) {
const loose = renderProps as unknown as LooseRenderProps;
controller.update(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return controller.onKeyDown(event);
},
onExit() {
controller.close();
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
'data-editor-inner': '',
class: [
'min-h-[120px] px-1 py-2.5',
'font-serif text-base leading-relaxed text-ink',
'focus:outline-none',
'tiptap-editor-inner'
].join(' ')
}
},
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);
}
});
});
onDestroy(() => {
cancelPendingSearch?.();
editor?.destroy();
// Tiptap suggestion onExit usually unmounts the dropdown, but if the host
// component is destroyed while a suggestion is active the dropdown can
// outlive the editor — clean it up explicitly.
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
}
});
// Keep the data-placeholder attribute in sync with actual emptiness so the
// placeholder CSS only fires when there is no content (not just on blur).
$effect(() => {
if (!editor || !placeholder) return;
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
// reactive read to re-run — void value registers value as a dependency without using it.
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
if (!inner) return;
if (editor.isEmpty) {
inner.setAttribute('data-placeholder', placeholder);
} else {
inner.removeAttribute('data-placeholder');
}
});
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
// flips contenteditable on the inner DOM and stops accepting input — matches
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
//
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
// idempotence check, the effect would loop on every prop pass-through.
$effect(() => {
const shouldBeEditable = !disabled;
if (editor && editor.isEditable !== shouldBeEditable) {
editor.setEditable(shouldBeEditable);
}
});
</script>
<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}
aria-disabled={disabled ? 'true' : undefined}
bind:this={editorEl}
></div>