The dropdown's editor-mirror clips at 100 chars (CWE-400, Nora #1), but the host editor previously fed renderProps.query directly to displayName on selection — so a 200-char @-suffix would search the first 100 chars but insert 200 chars. Clip once in updateState and use the clipped value for both the inserted displayName and the dropdown's editorQuery mirror, keeping "what I searched" and "what got inserted" in sync. Felix #3 on PR #629. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
377 lines
14 KiB
Svelte
377 lines
14 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 { 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;
|
|
};
|
|
|
|
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`).
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 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();
|
|
},
|
|
render() {
|
|
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;
|
|
};
|
|
|
|
// Request-token guard: every onSearch invocation bumps `requestId`;
|
|
// runSearch captures the id active when its fetch starts and discards
|
|
// the response if a newer onSearch has fired since. Without this, a
|
|
// late response can repopulate the dropdown after the user cleared
|
|
// the search input. Sara on PR #629.
|
|
let requestId = 0;
|
|
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?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
|
|
);
|
|
if (id !== requestId) return;
|
|
if (!res.ok) {
|
|
dropdownState.items = [];
|
|
return;
|
|
}
|
|
const data = (await res.json()) as Person[];
|
|
if (id !== requestId) return;
|
|
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
|
|
} catch {
|
|
if (id !== requestId) return;
|
|
dropdownState.items = [];
|
|
}
|
|
};
|
|
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
|
|
cancelPendingSearch = () => debouncedSearch.cancel();
|
|
const onSearch = (query: string) => {
|
|
requestId++;
|
|
if (query.trim() === '') {
|
|
debouncedSearch.cancel();
|
|
dropdownState.items = [];
|
|
return;
|
|
}
|
|
debouncedSearch(query);
|
|
};
|
|
|
|
const updateState = (renderProps: LooseRenderProps) => {
|
|
// Clip once here so both the inserted displayName and the
|
|
// dropdown's editor-mirror see the same value. The dropdown
|
|
// already clips the mirror (Nora #1 CWE-400), but without
|
|
// clipping at the command boundary an unclipped query would
|
|
// still flow through as the inserted displayName — visible
|
|
// UI divergence between "what I searched" and "what was
|
|
// inserted". Felix #3 on PR #629.
|
|
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
|
|
// AC-1: pass typed query as displayName, not person.displayName
|
|
dropdownState.command = (item: Person) =>
|
|
renderProps.command({
|
|
personId: item.id,
|
|
displayName: clippedQuery
|
|
});
|
|
dropdownState.clientRect = renderProps.clientRect ?? null;
|
|
dropdownState.editorQuery = clippedQuery;
|
|
};
|
|
|
|
return {
|
|
onStart(renderProps) {
|
|
const loose = renderProps as unknown as LooseRenderProps;
|
|
updateState(loose);
|
|
// MentionDropdown reads `editorQuery` off the shared state
|
|
// proxy via its `editorQuery` prop binding below — this is
|
|
// the same pattern as `model.items`. We do not pass it as a
|
|
// separate prop because Svelte 5's mount() does not expose
|
|
// settable prop accessors, so we route through the proxy.
|
|
const mounted = mount(MentionDropdown, {
|
|
target: document.body,
|
|
props: {
|
|
model: dropdownState,
|
|
get editorQuery() {
|
|
return dropdownState.editorQuery;
|
|
},
|
|
onSearch
|
|
}
|
|
});
|
|
mountedDropdown = 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() {
|
|
// Cancel any pending debounce so a closed dropdown's trailing
|
|
// runSearch cannot fire against the *next* dropdown's state.
|
|
// The hoisted `cancelPendingSearch` would be overwritten by
|
|
// the next render()'s onStart before the trailing call fires,
|
|
// so we cancel locally via the closure-scoped debouncedSearch.
|
|
// Felix #1 on PR #629.
|
|
debouncedSearch.cancel();
|
|
if (mountedDropdown) {
|
|
unmount(mountedDropdown);
|
|
mountedDropdown = 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(() => {
|
|
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>
|