361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
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="<a href="${profileUrl}" target="_blank">${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
|
||
·
|
||
<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; |