feat(#72): add @mention support in comment editor

- mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences)
- 19 unit tests in mention.spec.ts (all green)
- MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility
- CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)}
- types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-27 20:32:54 +01:00
parent e455efa670
commit 55cf1fb0a4
5 changed files with 468 additions and 16 deletions

View File

@@ -2,6 +2,9 @@
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte';
import { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
type Props = {
documentId: string;
@@ -32,6 +35,9 @@ let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
const commentsBase = $derived(
annotationId
@@ -76,13 +82,15 @@ async function postComment() {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
newText = '';
newMentionCandidates = [];
await reload();
}
} finally {
@@ -95,13 +103,15 @@ async function postReply(threadId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
replyText = '';
replyMentionCandidates = [];
replyingTo = null;
await reload();
}
@@ -115,13 +125,15 @@ async function saveEdit(commentId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
editingId = null;
editMentionCandidates = [];
await reload();
}
} finally {
@@ -147,6 +159,7 @@ async function deleteComment(commentId: string) {
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
editMentionCandidates = [];
}
function cancelEdit() {
@@ -181,11 +194,13 @@ onMount(() => {
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={3}
<MentionEditor
bind:value={editText}
></textarea>
bind:mentionCandidates={editMentionCandidates}
rows={3}
disabled={posting}
onsubmit={() => saveEdit(comment.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
@@ -215,7 +230,10 @@ onMount(() => {
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
@@ -283,12 +301,14 @@ onMount(() => {
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
<MentionEditor
bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
bind:value={replyText}
></textarea>
disabled={posting}
onsubmit={() => postReply(thread.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
@@ -313,12 +333,14 @@ onMount(() => {
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
bind:value={newText}
></textarea>
disabled={posting}
onsubmit={postComment}
/>
<div>
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { detectMention } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type Props = {
value: string;
mentionCandidates: MentionDTO[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onsubmit?: () => void;
};
let {
value = $bindable(''),
mentionCandidates = $bindable([]),
placeholder = '',
rows = 3,
disabled = false,
onsubmit
}: Props = $props();
let query: string | null = $state(null);
let results: MentionDTO[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
return () => {
textarea = null;
};
}
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
// Calculate where the @ starts
const before = value.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
mentionStart = atIndex;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q) {
results = [];
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: MentionDTO[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
}
}, 200);
}
function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
// Replace @partialQuery with @FirstName LastName (plus trailing space)
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
// Deduplicate and add to candidates
if (!mentionCandidates.some((c) => c.id === user.id)) {
mentionCandidates = [...mentionCandidates, user];
}
closePopup();
// Reposition cursor after the inserted mention
setTimeout(() => {
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}, 0);
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
clearTimeout(debounceTimer);
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
onsubmit?.();
return;
}
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();
selectUser(results[highlightedIndex]);
return;
}
}
function handleAtButtonClick() {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = value.slice(0, pos);
const after = value.slice(pos);
// Ensure @ is preceded by whitespace or is at the start
const needsSpace = before.length > 0 && !/\s$/.test(before);
const insertion = needsSpace ? ' @' : '@';
value = before + insertion + after;
setTimeout(() => {
if (!textarea) return;
const newPos = pos + insertion.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
// Trigger mention detection after inserting @
const detected = detectMention(value, newPos);
if (detected !== null) {
mentionStart = newPos - 1;
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}, 0);
}
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.mention_btn_label()}
>
{#if results.length === 0}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<button
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
selectUser(user);
}}
>
{user.firstName}
{user.lastName}
</button>
{/each}
{/if}
</div>
{/if}
<button
type="button"
aria-label={m.mention_btn_label()}
disabled={disabled}
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
onclick={handleAtButtonClick}
>
@
</button>
</div>

View File

@@ -1,3 +1,9 @@
export type MentionDTO = {
id: string;
firstName: string;
lastName: string;
};
export type CommentReply = {
id: string;
authorId: string | null;
@@ -5,6 +11,7 @@ export type CommentReply = {
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
export type Comment = {
@@ -15,6 +22,7 @@ export type Comment = {
createdAt: string;
updatedAt: string;
replies: CommentReply[];
mentionDTOs?: MentionDTO[];
};
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { detectMention, extractContent, renderBody } from './mention';
import type { MentionDTO } from '$lib/types';
// ─── detectMention ────────────────────────────────────────────────────────────
describe('detectMention', () => {
it('returns null when text has no @', () => {
expect(detectMention('hello world', 11)).toBeNull();
});
it('returns null when @ is not the most recent trigger word', () => {
// cursor is past a completed mention (next word started)
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
});
it('returns empty string immediately after @', () => {
expect(detectMention('hello @', 7)).toBe('');
});
it('returns query text after @', () => {
expect(detectMention('hello @Han', 10)).toBe('Han');
});
it('returns null when @ is preceded by a letter (email address pattern)', () => {
expect(detectMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectMention('@Hans', 5)).toBe('Hans');
});
it('returns null when cursor is before the @', () => {
expect(detectMention('@Hans', 0)).toBeNull();
});
});
// ─── extractContent ───────────────────────────────────────────────────────────
describe('extractContent', () => {
it('returns empty arrays for empty string', () => {
const result = extractContent('', []);
expect(result.content).toBe('');
expect(result.mentionedUserIds).toEqual([]);
});
it('returns plain content unchanged when no candidates', () => {
const result = extractContent('Hello world', []);
expect(result.content).toBe('Hello world');
expect(result.mentionedUserIds).toEqual([]);
});
it('extracts user id when @FirstName LastName is in content', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('Hey @Hans Müller how are you?', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('deduplicates user ids when same user mentioned twice', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
expect(result.mentionedUserIds).toHaveLength(1);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('collects multiple distinct users', () => {
const candidates: MentionDTO[] = [
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
];
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
expect(result.mentionedUserIds).toContain('uuid-2');
});
});
// ─── renderBody ───────────────────────────────────────────────────────────────
describe('renderBody', () => {
it('returns escaped plain text when no mentions', () => {
expect(renderBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in content', () => {
const result = renderBody('<script>alert(1)</script>', []);
expect(result).toContain('&lt;script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in content', () => {
const result = renderBody('AT&T', []);
expect(result).toContain('AT&amp;T');
});
it('wraps @mention in an anchor tag', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Hey @Hans Müller!', mentions);
expect(result).toContain('<a');
expect(result).toContain('Hans Müller');
});
it('does not double-encode already escaped text', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Check @Hans Müller', mentions);
expect(result).not.toContain('&amp;');
});
it('replaces all occurrences of the same mention', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
const linkCount = (result.match(/<a /g) ?? []).length;
expect(linkCount).toBe(2);
});
it('converts newlines to <br>', () => {
const result = renderBody('line1\nline2', []);
expect(result).toContain('<br>');
expect(result).not.toContain('\n');
});
});

View File

@@ -0,0 +1,67 @@
import type { MentionDTO } from '$lib/types';
/**
* Given the current textarea value and cursor position, returns the
* @-mention query being typed (the text after the last triggering @),
* or null if no mention is active.
*
* Rules:
* - @ must be preceded by whitespace or be at the start of the string
* - The text between @ and the cursor must not contain a space (a
* completed mention word already has a space)
*/
export function detectMention(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
if (atIndex === -1) return null;
// @ must be at start or preceded by whitespace
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
const query = before.slice(atIndex + 1);
// If the query contains a space the user has moved past the trigger word
if (query.includes(' ')) return null;
return query;
}
/**
* Given the raw textarea value and a list of candidate users (from the
* mention popup selections), returns the plain content string and the
* de-duplicated list of mentioned user IDs.
*/
export function extractContent(
text: string,
candidates: MentionDTO[]
): { content: string; mentionedUserIds: string[] } {
const seen = new Set<string>();
for (const user of candidates) {
const displayName = `${user.firstName} ${user.lastName}`.trim();
if (text.includes(`@${displayName}`)) {
seen.add(user.id);
}
}
return { content: text, mentionedUserIds: [...seen] };
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content
* 2. Replaces every @FirstName LastName occurrence with an anchor link
* 3. Converts newlines to <br>
*/
export function renderBody(content: string, mentions: MentionDTO[]): string {
let escaped = content
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
escaped = escaped.replaceAll(`@${displayName}`, link);
}
return escaped.replaceAll('\n', '<br>');
}