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>
474 lines
17 KiB
Svelte
474 lines
17 KiB
Svelte
<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>
|