feat(web): add comment composer with robust mention detection

This commit is contained in:
CREDO23 2026-01-15 20:35:21 +02:00
parent 90a8a17c88
commit 8bfcfdd084
3 changed files with 396 additions and 64 deletions

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { CommentComposer } from "@/components/chat-comments/comment-composer/comment-composer";
import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker";
import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types";
@ -40,10 +41,11 @@ const fakeMembersData: MemberOption[] = [
export default function ChatCommentsPreviewPage() {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [selectedMember, setSelectedMember] = useState<MemberOption | null>(null);
const [submittedContent, setSubmittedContent] = useState<string | null>(null);
return (
<div className="min-h-screen bg-background p-8">
<div className="mx-auto max-w-4xl space-y-8">
<div className="mx-auto max-w-4xl space-y-12">
<div>
<h1 className="text-2xl font-bold">Chat Comments UI Preview</h1>
<p className="text-muted-foreground">
@ -51,76 +53,104 @@ export default function ChatCommentsPreviewPage() {
</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
<section className="space-y-4">
<h2 className="text-lg font-semibold">After typing @</h2>
<p className="text-sm text-muted-foreground">Shows all members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query=""
highlightedIndex={highlightedIndex}
onSelect={(member) => setSelectedMember(member)}
onHighlightChange={setHighlightedIndex}
/>
{/* Comment Composer Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Comment Composer</h2>
<p className="text-sm text-muted-foreground">
Type @ to trigger mention picker. Use Tab/Shift+Tab/Arrow keys to navigate, Enter to select.
</p>
<div className="max-w-md rounded-lg border p-4">
<CommentComposer
members={fakeMembersData}
placeholder="Write a comment... (try typing @)"
onSubmit={(content) => setSubmittedContent(content)}
onCancel={() => setSubmittedContent(null)}
autoFocus
/>
</div>
{submittedContent && (
<div className="max-w-md rounded-md bg-muted p-3 text-sm">
<span className="font-medium">Submitted content: </span>
<code className="block mt-1 rounded bg-background p-2 whitespace-pre-wrap">
{submittedContent}
</code>
</div>
{selectedMember && (
<div className="rounded-md bg-muted p-3 text-sm">
<span className="font-medium">Selected: </span>
<code className="rounded bg-background px-1">
@[{selectedMember.id.slice(0, 8)}...]
</code>
<span className="text-muted-foreground">
{" → @"}
{selectedMember.displayName || selectedMember.email}
</span>
)}
</section>
{/* Member Mention Picker Section */}
<section className="space-y-4">
<h2 className="text-xl font-semibold border-b pb-2">Member Mention Picker (Standalone)</h2>
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-4">
<h3 className="text-lg font-medium">After typing @</h3>
<p className="text-sm text-muted-foreground">Shows all members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query=""
highlightedIndex={highlightedIndex}
onSelect={(member) => setSelectedMember(member)}
onHighlightChange={setHighlightedIndex}
/>
</div>
)}
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">After typing @ali</h2>
<p className="text-sm text-muted-foreground">Filtered to matching members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query="ali"
highlightedIndex={0}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
{selectedMember && (
<div className="rounded-md bg-muted p-3 text-sm">
<span className="font-medium">Selected: </span>
<code className="rounded bg-background px-1">
@[{selectedMember.id.slice(0, 8)}...]
</code>
</div>
)}
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Loading State</h2>
<p className="text-sm text-muted-foreground">While fetching members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={[]}
query=""
highlightedIndex={0}
isLoading={true}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
<div className="space-y-4">
<h3 className="text-lg font-medium">After typing @ali</h3>
<p className="text-sm text-muted-foreground">Filtered to matching members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query="ali"
highlightedIndex={0}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold">No Results</h2>
<p className="text-sm text-muted-foreground">After typing @xyz (no match)</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query="xyz"
highlightedIndex={0}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
<div className="space-y-4">
<h3 className="text-lg font-medium">Loading State</h3>
<p className="text-sm text-muted-foreground">While fetching members</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={[]}
query=""
highlightedIndex={0}
isLoading={true}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
</div>
</div>
</section>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium">No Results</h3>
<p className="text-sm text-muted-foreground">After typing @xyz (no match)</p>
<div className="w-72 rounded-lg border bg-popover shadow-lg">
<MemberMentionPicker
members={fakeMembersData}
query="xyz"
highlightedIndex={0}
onSelect={() => {}}
onHighlightChange={() => {}}
/>
</div>
</div>
</div>
</section>
</div>
</div>
);

View file

@ -0,0 +1,278 @@
"use client";
import { Send, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker";
import type { MemberOption } from "../member-mention-picker/types";
import type { CommentComposerProps, InsertedMention, MentionState } from "./types";
function convertDisplayToData(
displayContent: string,
mentions: InsertedMention[]
): string {
let result = displayContent;
const sortedMentions = [...mentions].sort(
(a, b) => b.displayName.length - a.displayName.length
);
for (const mention of sortedMentions) {
const displayPattern = new RegExp(`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`, 'g');
const dataFormat = `@[${mention.id}]`;
result = result.replace(displayPattern, dataFormat);
}
return result;
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function findMentionTrigger(
text: string,
cursorPos: number,
insertedMentions: InsertedMention[]
): { isActive: boolean; query: string; startIndex: number } {
const textBeforeCursor = text.slice(0, cursorPos);
const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
if (!mentionMatch) {
return { isActive: false, query: "", startIndex: 0 };
}
const fullMatch = mentionMatch[0];
const query = mentionMatch[1];
const atIndex = cursorPos - query.length - 1;
if (atIndex > 0) {
const charBefore = text[atIndex - 1];
if (charBefore && !/[\s]/.test(charBefore)) {
return { isActive: false, query: "", startIndex: 0 };
}
}
const textFromAt = text.slice(atIndex);
for (const mention of insertedMentions) {
const mentionPattern = `@${mention.displayName}`;
if (textFromAt.startsWith(mentionPattern)) {
const charAfterMention = text[atIndex + mentionPattern.length];
if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) {
if (cursorPos <= atIndex + mentionPattern.length) {
return { isActive: false, query: "", startIndex: 0 };
}
}
}
}
if (query.length > 50) {
return { isActive: false, query: "", startIndex: 0 };
}
return { isActive: true, query, startIndex: atIndex };
}
export function CommentComposer({
members,
membersLoading = false,
placeholder = "Write a comment...",
submitLabel = "Send",
isSubmitting = false,
onSubmit,
onCancel,
autoFocus = false,
}: CommentComposerProps) {
const [displayContent, setDisplayContent] = useState("");
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
const [mentionState, setMentionState] = useState<MentionState>({
isActive: false,
query: "",
startIndex: 0,
});
const [highlightedIndex, setHighlightedIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const filteredMembers = mentionState.query
? members.filter(
(member) =>
member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) ||
member.email.toLowerCase().includes(mentionState.query.toLowerCase())
)
: members;
const closeMentionPicker = useCallback(() => {
setMentionState({ isActive: false, query: "", startIndex: 0 });
setHighlightedIndex(0);
}, []);
const insertMention = useCallback(
(member: MemberOption) => {
const displayName = member.displayName || member.email.split("@")[0];
const before = displayContent.slice(0, mentionState.startIndex);
const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length;
const after = displayContent.slice(cursorPos);
const mentionText = `@${displayName} `;
const newContent = before + mentionText + after;
setDisplayContent(newContent);
setInsertedMentions((prev) => {
const exists = prev.some((m) => m.id === member.id && m.displayName === displayName);
if (exists) return prev;
return [...prev, { id: member.id, displayName }];
});
closeMentionPicker();
requestAnimationFrame(() => {
if (textareaRef.current) {
const cursorPos = before.length + mentionText.length;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(cursorPos, cursorPos);
}
});
},
[displayContent, mentionState.startIndex, closeMentionPicker]
);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setDisplayContent(value);
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
if (triggerResult.isActive) {
setMentionState(triggerResult);
setHighlightedIndex(0);
} else if (mentionState.isActive) {
closeMentionPicker();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.isActive) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
return;
}
switch (e.key) {
case "ArrowDown":
case "Tab":
if (!e.shiftKey) {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredMembers.length - 1 ? prev + 1 : 0
);
} else if (e.key === "Tab") {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredMembers.length - 1
);
}
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredMembers.length - 1
);
break;
case "Enter":
e.preventDefault();
if (filteredMembers[highlightedIndex]) {
insertMention(filteredMembers[highlightedIndex]);
}
break;
case "Escape":
e.preventDefault();
closeMentionPicker();
break;
}
};
const handleSubmit = () => {
const trimmed = displayContent.trim();
if (!trimmed || isSubmitting) return;
const dataContent = convertDisplayToData(trimmed, insertedMentions);
onSubmit(dataContent);
setDisplayContent("");
setInsertedMentions([]);
};
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
return (
<div className="flex flex-col gap-2">
<Popover open={mentionState.isActive} onOpenChange={(open) => !open && closeMentionPicker()}>
<PopoverAnchor asChild>
<Textarea
ref={textareaRef}
value={displayContent}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-[80px] resize-none"
disabled={isSubmitting}
/>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
sideOffset={4}
collisionPadding={8}
className="w-72 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MemberMentionPicker
members={members}
query={mentionState.query}
highlightedIndex={highlightedIndex}
isLoading={membersLoading}
onSelect={insertMention}
onHighlightChange={setHighlightedIndex}
/>
</PopoverContent>
</Popover>
<div className="flex items-center justify-end gap-2">
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="mr-1 size-4" />
Cancel
</Button>
)}
<Button
type="button"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(!canSubmit && "opacity-50")}
>
<Send className="mr-1 size-4" />
{submitLabel}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { MemberOption } from "../member-mention-picker/types";
export interface CommentComposerProps {
members: MemberOption[];
membersLoading?: boolean;
placeholder?: string;
submitLabel?: string;
isSubmitting?: boolean;
onSubmit: (content: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
}
export interface MentionState {
isActive: boolean;
query: string;
startIndex: number;
}
export interface InsertedMention {
id: string;
displayName: string;
}