mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
feat(web): add comment composer with robust mention detection
This commit is contained in:
parent
90a8a17c88
commit
8bfcfdd084
3 changed files with 396 additions and 64 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue