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:
@@ -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"
|
||||
|
||||
235
frontend/src/lib/components/MentionEditor.svelte
Normal file
235
frontend/src/lib/components/MentionEditor.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user