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 { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment, CommentReply } from '$lib/types';
|
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 = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -32,6 +35,9 @@ let replyText: string = $state('');
|
|||||||
let editingId: string | null = $state(null);
|
let editingId: string | null = $state(null);
|
||||||
let editText: string = $state('');
|
let editText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
|
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
|
||||||
const commentsBase = $derived(
|
const commentsBase = $derived(
|
||||||
annotationId
|
annotationId
|
||||||
@@ -76,13 +82,15 @@ async function postComment() {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
|
||||||
const res = await fetch(commentsBase, {
|
const res = await fetch(commentsBase, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
newText = '';
|
newText = '';
|
||||||
|
newMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,13 +103,15 @@ async function postReply(threadId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
replyText = '';
|
replyText = '';
|
||||||
|
replyMentionCandidates = [];
|
||||||
replyingTo = null;
|
replyingTo = null;
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
@@ -115,13 +125,15 @@ async function saveEdit(commentId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
|
editMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,6 +159,7 @@ async function deleteComment(commentId: string) {
|
|||||||
function startEdit(comment: Comment | CommentReply) {
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
editingId = comment.id;
|
editingId = comment.id;
|
||||||
editText = comment.content;
|
editText = comment.content;
|
||||||
|
editMentionCandidates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -181,11 +194,13 @@ onMount(() => {
|
|||||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||||
{#if editingId === comment.id}
|
{#if editingId === comment.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
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}
|
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
></textarea>
|
bind:mentionCandidates={editMentionCandidates}
|
||||||
|
rows={3}
|
||||||
|
disabled={posting}
|
||||||
|
onsubmit={() => saveEdit(comment.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{#if canModify(comment)}
|
{#if canModify(comment)}
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
@@ -283,12 +301,14 @@ onMount(() => {
|
|||||||
<!-- Reply compose box -->
|
<!-- Reply compose box -->
|
||||||
{#if replyingTo === thread.id}
|
{#if replyingTo === thread.id}
|
||||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
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"
|
bind:value={replyText}
|
||||||
|
bind:mentionCandidates={replyMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={replyText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={() => postReply(thread.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
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}
|
{#if canComment}
|
||||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
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"
|
bind:value={newText}
|
||||||
|
bind:mentionCandidates={newMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={newText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={postComment}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<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"
|
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>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export type MentionDTO = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommentReply = {
|
export type CommentReply = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
@@ -5,6 +11,7 @@ export type CommentReply = {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
@@ -15,6 +22,7 @@ export type Comment = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
replies: CommentReply[];
|
replies: CommentReply[];
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|||||||
120
frontend/src/lib/utils/mention.spec.ts
Normal file
120
frontend/src/lib/utils/mention.spec.ts
Normal 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('<script>');
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes & in content', () => {
|
||||||
|
const result = renderBody('AT&T', []);
|
||||||
|
expect(result).toContain('AT&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('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/utils/mention.ts
Normal file
67
frontend/src/lib/utils/mention.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
|
||||||
|
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>');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user