2026-03-23 17:43:17 +05:30

361 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { FaSpinner, FaUser } from 'react-icons/fa';
import type { MentionUser } from '../services/commentService';
import API_CONFIG from '../config/api';
// ============================================================
// MentionInput a textarea that shows a dropdown when user
// types '@' and lets them pick a user to @mention.
//
// The final output is an HTML string with Frappe-style mention
// markup so ERPNext recognises it exactly like its own editor.
// ============================================================
interface MentionInputProps {
value: string; // plain-text draft
onChange: (text: string) => void;
onSubmit: (html: string) => void; // returns formatted HTML
placeholder?: string;
disabled?: boolean;
mentionUsers: MentionUser[];
mentionLoading: boolean;
onMentionSearch: (query: string) => void;
posting?: boolean;
}
/** Stores an inserted mention so we can convert to HTML later */
interface InsertedMention {
startIndex: number;
displayText: string;
userId: string; // email
fullName: string;
}
const MentionInput: React.FC<MentionInputProps> = ({
value,
onChange,
onSubmit,
placeholder = 'Type a comment… Use @ to mention someone',
disabled = false,
mentionUsers,
mentionLoading,
onMentionSearch,
posting = false,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Mention trigger state
const [showMentionDropdown, setShowMentionDropdown] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartPos, setMentionStartPos] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
// Dropdown position
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
// ── Calculate dropdown position relative to textarea ────
const updateDropdownPosition = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
// Place the dropdown above the textarea bottom
const rect = ta.getBoundingClientRect();
const parentRect = ta.offsetParent?.getBoundingClientRect() ?? rect;
setDropdownPos({
top: ta.offsetTop - 4, // above textarea
left: ta.offsetLeft,
});
}, []);
// ── Handle text changes ─────────────────────────────────
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart ?? 0;
onChange(newValue);
// Check for active mention trigger
const textBeforeCursor = newValue.substring(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
// Check that @ is at start or preceded by a space/newline
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.substring(lastAtIndex + 1);
// Only activate if query doesn't contain spaces (single-word search)
if (!query.includes(' ') || query.length <= 30) {
setShowMentionDropdown(true);
setMentionQuery(query);
setMentionStartPos(lastAtIndex);
setSelectedIndex(0);
onMentionSearch(query);
updateDropdownPosition();
return;
}
}
}
// No active mention
setShowMentionDropdown(false);
setMentionStartPos(null);
};
// ── Insert a mention into the text ──────────────────────
const insertMention = useCallback(
(user: MentionUser) => {
if (mentionStartPos === null) return;
const ta = textareaRef.current;
const before = value.substring(0, mentionStartPos);
const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1;
const after = value.substring(cursorPos);
const displayText = user.full_name || user.name;
const newText = `${before}@${displayText} ${after}`;
// Track the mention
setInsertedMentions((prev) => [
...prev,
{
startIndex: mentionStartPos,
displayText,
userId: user.name,
fullName: user.full_name || user.name,
},
]);
onChange(newText);
setShowMentionDropdown(false);
setMentionStartPos(null);
setMentionQuery('');
// Refocus and place cursor after mention
setTimeout(() => {
if (ta) {
ta.focus();
const newPos = before.length + displayText.length + 2; // +2 for @ and space
ta.selectionStart = newPos;
ta.selectionEnd = newPos;
}
}, 0);
},
[mentionStartPos, mentionQuery, value, onChange]
);
// ── Keyboard navigation inside dropdown ─────────────────
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionDropdown && mentionUsers.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1));
return;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
case 'Enter':
e.preventDefault();
insertMention(mentionUsers[selectedIndex]);
return;
case 'Escape':
e.preventDefault();
setShowMentionDropdown(false);
return;
}
}
// Ctrl/Cmd + Enter to submit
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
// ── Build Frappe-compatible HTML from plain text + mentions ─
const buildHtml = useCallback(
(text: string): string => {
let html = text;
const baseUrl = API_CONFIG.BASE_URL || window.location.origin;
// Replace each tracked @mention with the Frappe mention markup
// Process in reverse order of startIndex so positions don't shift
const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex);
for (const m of sorted) {
const mentionText = `@${m.displayText}`;
const idx = html.indexOf(mentionText);
if (idx === -1) continue;
const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`;
const mentionHtml =
`<span class="mention" ` +
`data-id="${m.userId}" ` +
`data-value="&lt;a href=&quot;${profileUrl}&quot; target=&quot;_blank&quot;&gt;${m.fullName}" ` +
`data-denotation-char="@" ` +
`data-is-group="false" ` +
`data-link="${profileUrl}">` +
`\uFEFF<span contenteditable="false">` +
`<span class="ql-mention-denotation-char">@</span>` +
`<a href="${profileUrl}" target="_blank">${m.fullName}</a>` +
`</span>\uFEFF</span>`;
html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length);
}
// Escape remaining HTML chars (basic), then wrap newlines
// We do NOT escape the mention markup we just inserted
// Instead, split by mention spans, escape non-mention parts, and rejoin
// Simple approach: since mentions are already HTML, just convert newlines to <br>
html = html.replace(/\n/g, '<br>');
return `<div class="ql-editor read-mode"><p>${html}</p></div>`;
},
[insertedMentions]
);
// ── Submit handler ──────────────────────────────────────
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || posting) return;
const html = buildHtml(trimmed);
onSubmit(html);
setInsertedMentions([]);
};
// ── Close dropdown on outside click ─────────────────────
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(e.target as Node)
) {
setShowMentionDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// ── Scroll selected item into view ──────────────────────
useEffect(() => {
if (!dropdownRef.current) return;
const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`);
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const baseUrl = API_CONFIG.BASE_URL || '';
return (
<div className="relative">
{/* ── Mention dropdown ──────────────────────────────── */}
{showMentionDropdown && (
<div
ref={dropdownRef}
className="absolute z-50 bottom-full mb-1 w-72 max-h-52 overflow-y-auto
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600
rounded-lg shadow-lg"
style={{ left: 0 }}
>
{mentionLoading && mentionUsers.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<FaSpinner className="animate-spin" size={12} />
Searching users
</div>
) : mentionUsers.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
No users found
</div>
) : (
mentionUsers.map((user, idx) => (
<button
key={user.name}
data-idx={idx}
type="button"
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors
${idx === selectedIndex
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-800 dark:text-teal-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onMouseEnter={() => setSelectedIndex(idx)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus on textarea
insertMention(user);
}}
>
{/* Avatar */}
{user.user_image ? (
<img
src={`${baseUrl}${user.user_image}`}
alt=""
className="w-7 h-7 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<FaUser className="text-gray-500 dark:text-gray-400" size={10} />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{user.full_name || user.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user.name}
</p>
</div>
</button>
))
)}
</div>
)}
{/* ── Textarea + submit ──────────────────────────────── */}
<div className="flex gap-2 items-end">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || posting}
rows={3}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm
disabled:bg-gray-100 dark:disabled:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
<button
type="button"
onClick={handleSubmit}
disabled={disabled || posting || !value.trim()}
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50
text-white text-sm font-medium rounded-lg transition-colors
disabled:cursor-not-allowed flex items-center gap-1.5 h-10 flex-shrink-0"
>
{posting ? (
<>
<FaSpinner className="animate-spin" size={12} />
<span>Posting</span>
</>
) : (
<span>Comment</span>
)}
</button>
</div>
{/* Hint */}
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">@</kbd> to mention
&nbsp;·&nbsp;
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">Ctrl+Enter</kbd> to post
</p>
</div>
);
};
export default MentionInput;