Files
familienarchiv/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

296 lines
9.8 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 MentionDropdown from './MentionDropdown.svelte';
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;
// 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
});
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
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
})
}
};
}
});
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`).
// ─────────────────────────────────────────────────────────────
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;
// 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;
};
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(),
'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(() => {
editor?.destroy();
});
// 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>