useMemo received deps as trailing comma-separated args instead of array. Replace all render-body reads of mentionStateRef.current with reactive mentionState from useMemo. Keep ref for event listener closures only.
511 lines
23 KiB
TypeScript
511 lines
23 KiB
TypeScript
import { buildMentionHtml, type Node } from '@/lib/mention-ast';
|
|
import type { MentionSuggestion, MentionType, RoomAiConfig } from '@/lib/mention-types';
|
|
import { cn } from '@/lib/utils';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import {
|
|
Bot, ChevronRight, Database, SearchX, Sparkles, User, Check,
|
|
} from 'lucide-react';
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface PopoverPosition { top: number; left: number }
|
|
|
|
interface MentionPopoverProps {
|
|
members: Array<{ user: string; user_info?: { username?: string; avatar_url?: string }; role?: string }>;
|
|
repos: Array<{ uid: string; repo_name: string }> | null;
|
|
aiConfigs: RoomAiConfig[];
|
|
reposLoading: boolean;
|
|
aiConfigsLoading: boolean;
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
/** Plain text value of the input */
|
|
inputValue: string;
|
|
/** Current caret offset in the text */
|
|
cursorPosition: number;
|
|
onSelect: (newValue: string, newCursorPos: number) => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
onCategoryEnter: (category: string) => void;
|
|
suggestions: MentionSuggestion[];
|
|
selectedIndex: number;
|
|
setSelectedIndex: (index: number) => void;
|
|
}
|
|
|
|
// ─── Category Configs ────────────────────────────────────────────────────────
|
|
|
|
const CATEGORY_CONFIG: Record<string, { icon: JSX.Element; color: string; bgColor: string; borderColor: string; gradient: string }> = {
|
|
repository: {
|
|
icon: <Database className="h-3.5 w-3.5" />, color: 'text-violet-600 dark:text-violet-400',
|
|
bgColor: 'bg-violet-500/10 dark:bg-violet-500/15', borderColor: 'border-violet-500/20',
|
|
gradient: 'from-violet-500/5 to-transparent',
|
|
},
|
|
user: {
|
|
icon: <User className="h-3.5 w-3.5" />, color: 'text-sky-600 dark:text-sky-400',
|
|
bgColor: 'bg-sky-500/10 dark:bg-sky-500/15', borderColor: 'border-sky-500/20',
|
|
gradient: 'from-sky-500/5 to-transparent',
|
|
},
|
|
ai: {
|
|
icon: <Sparkles className="h-3.5 w-3.5" />, color: 'text-emerald-600 dark:text-emerald-400',
|
|
bgColor: 'bg-emerald-500/10 dark:bg-emerald-500/15', borderColor: 'border-emerald-500/20',
|
|
gradient: 'from-emerald-500/5 to-transparent',
|
|
},
|
|
};
|
|
|
|
const CATEGORIES: MentionSuggestion[] = [
|
|
{ type: 'category', category: 'repository', label: 'Repository' },
|
|
{ type: 'category', category: 'user', label: 'User' },
|
|
{ type: 'category', category: 'ai', label: 'AI' },
|
|
];
|
|
|
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
|
|
function escapeRegExp(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// ─── Sub-components ──────────────────────────────────────────────────────────
|
|
|
|
function HighlightMatch({ text, match }: { text: string; match: string }) {
|
|
if (!match) return <>{text}</>;
|
|
const parts = text.split(new RegExp(`(${escapeRegExp(match)})`, 'gi'));
|
|
return (
|
|
<>
|
|
{parts.map((part, i) =>
|
|
/[@a-z]/i.test(part) && new RegExp(escapeRegExp(match), 'gi').test(part) ? (
|
|
<span key={i} className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 font-semibold rounded px-0.5">
|
|
{part}
|
|
</span>
|
|
) : (
|
|
<span key={i}>{part}</span>
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CategoryHeader({ suggestion, isSelected }: { suggestion: MentionSuggestion; isSelected: boolean }) {
|
|
const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null;
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center gap-2 px-3 py-2.5 text-xs font-medium text-muted-foreground transition-colors duration-150',
|
|
isSelected && 'text-foreground bg-muted/50',
|
|
)}>
|
|
<div className={cn('flex h-5 w-5 items-center justify-center rounded-md border', config?.bgColor, config?.borderColor, config?.color)}>
|
|
{config?.icon ?? <ChevronRight className="h-3 w-3" />}
|
|
</div>
|
|
<span className="flex-1">{suggestion.label}</span>
|
|
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuggestionItem({ suggestion, isSelected, onSelect, onMouseEnter, searchTerm }: {
|
|
suggestion: MentionSuggestion; isSelected: boolean;
|
|
onSelect: () => void; onMouseEnter: () => void; searchTerm: string;
|
|
}) {
|
|
const config = suggestion.category ? CATEGORY_CONFIG[suggestion.category] : null;
|
|
|
|
return (
|
|
<div className={cn(
|
|
'group relative flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-all duration-150 ease-out border-l-2 border-transparent',
|
|
isSelected && ['bg-gradient-to-r', config?.gradient ?? 'from-muted to-transparent', 'border-l-current', config?.color ?? 'text-foreground'],
|
|
!isSelected && 'hover:bg-muted/40',
|
|
)}
|
|
onPointerDown={(e) => e.preventDefault()}
|
|
onClick={onSelect} onMouseEnter={onMouseEnter}
|
|
>
|
|
{/* Icon */}
|
|
<div className="relative shrink-0">
|
|
{suggestion.category === 'ai' ? (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/20 shadow-sm">
|
|
<Bot className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
|
</div>
|
|
) : suggestion.category === 'repository' ? (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/20 to-violet-600/10 border border-violet-500/20 shadow-sm">
|
|
<Database className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
|
</div>
|
|
) : suggestion.avatar ? (
|
|
<Avatar size="sm" className="h-8 w-8 ring-2 ring-background">
|
|
<AvatarImage src={suggestion.avatar} alt={suggestion.label} />
|
|
<AvatarFallback className="bg-sky-500/10 text-sky-600 text-xs font-medium">
|
|
{suggestion.label.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
) : (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500/20 to-sky-600/10 border border-sky-500/20 shadow-sm">
|
|
<span className="text-xs font-semibold text-sky-600 dark:text-sky-400">
|
|
{suggestion.label[0]?.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* Selection dot */}
|
|
<div className={cn(
|
|
'absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full bg-primary border-2 border-background transition-transform duration-150',
|
|
isSelected ? 'scale-100' : 'scale-0',
|
|
)} />
|
|
</div>
|
|
|
|
{/* Text */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="block truncate text-sm font-medium">
|
|
<HighlightMatch text={suggestion.label} match={searchTerm} />
|
|
</span>
|
|
{suggestion.category && (
|
|
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 border', config?.bgColor, config?.color, config?.borderColor)}>
|
|
{suggestion.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{suggestion.sublabel && (
|
|
<span className="block truncate text-xs text-muted-foreground mt-0.5">{suggestion.sublabel}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Check mark */}
|
|
<div className={cn(
|
|
'flex items-center justify-center w-5 h-5 rounded-full transition-all duration-150',
|
|
isSelected ? 'bg-primary/10 text-primary' : 'opacity-0 group-hover:opacity-30',
|
|
)}>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadingSkeleton() {
|
|
return (
|
|
<div className="px-3 py-3 space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="flex items-center gap-3 py-1">
|
|
<Skeleton className="h-8 w-8 rounded-lg shrink-0" />
|
|
<div className="flex-1 space-y-1.5"><Skeleton className="h-3.5 w-24" /><Skeleton className="h-3 w-16" /></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ loading }: { loading?: boolean }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center px-4 py-8 text-center">
|
|
<div className={cn('flex h-12 w-12 items-center justify-center rounded-xl mb-3 bg-muted/80')}>
|
|
{loading ? (
|
|
<div className="h-5 w-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
|
|
) : (
|
|
<SearchX className="h-5 w-5 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<p className="text-sm font-medium text-foreground">{loading ? 'Loading...' : 'No matches found'}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">{loading ? 'Please wait' : 'Try a different search term'}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
|
|
export function MentionPopover({
|
|
members, repos, aiConfigs, reposLoading, aiConfigsLoading,
|
|
containerRef, inputValue, cursorPosition, onSelect, onOpenChange, onCategoryEnter,
|
|
suggestions, selectedIndex, setSelectedIndex,
|
|
}: MentionPopoverProps) {
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const itemsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
// Stable refs for event listener closure
|
|
const mentionStateRef = useRef<{ category: string; item: string; hasColon: boolean } | null>(null);
|
|
const visibleSuggestionsRef = useRef<MentionSuggestion[]>([]);
|
|
const selectedIndexRef = useRef(selectedIndex);
|
|
const handleSelectRef = useRef<() => void>(() => {});
|
|
const closePopoverRef = useRef<() => void>(() => {});
|
|
const onCategoryEnterRef = useRef(onCategoryEnter);
|
|
onCategoryEnterRef.current = onCategoryEnter;
|
|
|
|
// Parse mention state
|
|
const mentionState = useMemo(() => {
|
|
const before = inputValue.slice(0, cursorPosition);
|
|
const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
|
if (!match) return null;
|
|
return { category: match[1].toLowerCase(), item: (match[3] ?? '').toLowerCase(), hasColon: match[2] !== undefined };
|
|
}, [inputValue, cursorPosition]);
|
|
mentionStateRef.current = mentionState;
|
|
|
|
visibleSuggestionsRef.current = suggestions;
|
|
selectedIndexRef.current = selectedIndex;
|
|
|
|
// Auto-select first item
|
|
useEffect(() => { setSelectedIndex(0); }, [suggestions.length]);
|
|
|
|
// Scroll selected into view
|
|
useLayoutEffect(() => {
|
|
const el = itemsRef.current[selectedIndex];
|
|
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}, [selectedIndex]);
|
|
|
|
const closePopover = useCallback(() => onOpenChange(false), [onOpenChange]);
|
|
closePopoverRef.current = closePopover;
|
|
|
|
// ─── Insertion Logic ───────────────────────────────────────────────────────
|
|
|
|
const doInsert = useCallback((suggestion: MentionSuggestion) => {
|
|
const textarea = containerRef.current;
|
|
if (!textarea || suggestion.type !== 'item') return;
|
|
|
|
// Get current DOM text and cursor position
|
|
const textBeforeCursor = (() => {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return inputValue.slice(0, cursorPosition);
|
|
const range = sel.getRangeAt(0);
|
|
const pre = range.cloneRange();
|
|
pre.selectNodeContents(textarea);
|
|
pre.setEnd(range.startContainer, range.startOffset);
|
|
let count = 0;
|
|
const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode as Text;
|
|
if (node === range.startContainer) { count += range.startOffset; break; }
|
|
count += node.length;
|
|
}
|
|
return inputValue.slice(0, count);
|
|
})();
|
|
|
|
const atMatch = textBeforeCursor.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
|
|
if (!atMatch) return;
|
|
|
|
const [fullPattern] = atMatch;
|
|
const startPos = textBeforeCursor.length - fullPattern.length;
|
|
const before = textBeforeCursor.substring(0, startPos);
|
|
// Reconstruct after: from DOM text after cursor
|
|
const domText = (() => {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return inputValue.substring(cursorPosition);
|
|
const range = sel.getRangeAt(0);
|
|
const post = range.cloneRange();
|
|
post.setStart(range.endContainer, range.endOffset);
|
|
let count = 0;
|
|
const tree = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT);
|
|
while (tree.nextNode()) count += (tree.currentNode as Text).length;
|
|
return inputValue.substring(cursorPosition);
|
|
})();
|
|
|
|
const html = buildMentionHtml(
|
|
suggestion.category!, suggestion.mentionId!, suggestion.label,
|
|
);
|
|
const spacer = ' ';
|
|
const newValue = before + html + spacer + domText;
|
|
const newCursorPos = startPos + html.length + spacer.length;
|
|
|
|
onSelect(newValue, newCursorPos);
|
|
closePopover();
|
|
}, [containerRef, inputValue, cursorPosition, onSelect, closePopover]);
|
|
|
|
handleSelectRef.current = doInsert;
|
|
|
|
// ─── Positioning ───────────────────────────────────────────────────────────
|
|
|
|
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
|
|
|
|
useLayoutEffect(() => {
|
|
if (!containerRef.current) return;
|
|
if (!mentionState) return;
|
|
|
|
const textarea = containerRef.current;
|
|
const styles = window.getComputedStyle(textarea);
|
|
|
|
// Measure @pattern position using hidden clone div
|
|
const tempDiv = document.createElement('span');
|
|
tempDiv.style.cssText = getComputedStyleStyles(styles);
|
|
tempDiv.style.position = 'absolute';
|
|
tempDiv.style.visibility = 'hidden';
|
|
tempDiv.style.whiteSpace = 'pre-wrap';
|
|
tempDiv.style.pointerEvents = 'none';
|
|
document.body.appendChild(tempDiv);
|
|
|
|
const pattern = mentionState.hasColon ? `@${mentionState.category}:${mentionState.item}` : `@${mentionState.category}`;
|
|
tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern)));
|
|
|
|
const span = document.createElement('span');
|
|
span.textContent = '|';
|
|
tempDiv.appendChild(span);
|
|
|
|
const rect = textarea.getBoundingClientRect();
|
|
const borderTop = parseFloat(styles.borderTopWidth) || 0;
|
|
const borderLeft = parseFloat(styles.borderLeftWidth) || 0;
|
|
const scrollTop = textarea.scrollTop;
|
|
const scrollLeft = textarea.scrollLeft;
|
|
|
|
const lineHeight = parseFloat(styles.lineHeight) || 20;
|
|
const contentTop = rect.top + borderTop + span.offsetTop - scrollTop;
|
|
const contentLeft = rect.left + borderLeft + span.offsetLeft - scrollLeft;
|
|
|
|
// Append clone for measurement
|
|
const clone = textarea.cloneNode(true) as HTMLDivElement;
|
|
clone.style.cssText = getComputedStyleStyles(styles);
|
|
clone.style.position = 'fixed';
|
|
clone.style.top = '-9999px';
|
|
clone.style.left = '-9999px';
|
|
clone.style.width = styles.width;
|
|
clone.style.maxWidth = styles.maxWidth;
|
|
clone.innerHTML = '';
|
|
const textSpan = document.createElement('span');
|
|
textSpan.style.whiteSpace = 'pre';
|
|
textSpan.textContent = inputValue.slice(0, cursorPosition);
|
|
clone.appendChild(textSpan);
|
|
const cursorSpan = document.createElement('span');
|
|
cursorSpan.textContent = '|';
|
|
clone.appendChild(cursorSpan);
|
|
document.body.appendChild(clone);
|
|
document.body.removeChild(clone);
|
|
|
|
const popoverWidth = 320;
|
|
const popoverHeight = Math.min(360, suggestions.length * 56 + 100);
|
|
const vp = 16;
|
|
|
|
const left = Math.max(Math.max(contentLeft, vp), window.innerWidth - popoverWidth - vp);
|
|
const shouldBelow = contentTop + lineHeight + 8 + popoverHeight < window.innerHeight - vp;
|
|
const top = shouldBelow
|
|
? contentTop + lineHeight + 8
|
|
: Math.max(vp, contentTop - popoverHeight - 8);
|
|
|
|
setPosition({ top, left: contentLeft });
|
|
|
|
document.body.removeChild(tempDiv);
|
|
|
|
// Simple approach: just use the textarea bounds + estimated offset
|
|
// This is sufficient for most cases
|
|
}, [inputValue, cursorPosition, containerRef, mentionState, suggestions.length]);
|
|
|
|
// ─── Keyboard Navigation ───────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const textarea = containerRef.current;
|
|
if (!textarea) return;
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const vis = visibleSuggestionsRef.current;
|
|
if (vis.length === 0) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
const next = (selectedIndexRef.current + 1) % Math.max(vis.length, 1);
|
|
setSelectedIndex(next);
|
|
selectedIndexRef.current = next;
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const prev = (selectedIndexRef.current - 1 + vis.length) % Math.max(vis.length, 1);
|
|
setSelectedIndex(prev);
|
|
selectedIndexRef.current = prev;
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
const item = vis[selectedIndexRef.current];
|
|
if (!item) return;
|
|
e.preventDefault();
|
|
if (item.type === 'category') onCategoryEnterRef.current(item.category!);
|
|
else handleSelectRef.current();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closePopoverRef.current();
|
|
}
|
|
};
|
|
|
|
textarea.addEventListener('keydown', onKeyDown);
|
|
return () => textarea.removeEventListener('keydown', onKeyDown);
|
|
}, [containerRef, setSelectedIndex]);
|
|
|
|
// ─── Close on Outside Click ────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
const handler = (e: PointerEvent) => {
|
|
const t = e.target as Node;
|
|
if (popoverRef.current?.contains(t)) return;
|
|
if (containerRef.current?.contains(t)) return;
|
|
closePopover();
|
|
};
|
|
document.addEventListener('pointerdown', handler);
|
|
return () => document.removeEventListener('pointerdown', handler);
|
|
}, [closePopover, containerRef]);
|
|
|
|
// Hide when mention state is gone
|
|
useEffect(() => {
|
|
if (!mentionState) closePopover();
|
|
}, [inputValue, cursorPosition, closePopover, mentionState]);
|
|
|
|
// Don't render if no valid mention context
|
|
if (!mentionState) return null;
|
|
|
|
const isLoading = (mentionState.category === 'repository' && reposLoading) ||
|
|
(mentionState.category === 'ai' && aiConfigsLoading);
|
|
|
|
const currentCategory = mentionState.hasColon ? mentionState.category : null;
|
|
const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null;
|
|
|
|
return (
|
|
<div ref={popoverRef} className="animate-in fade-in zoom-in-95 duration-150 ease-out fixed z-50 w-80 overflow-hidden rounded-xl border border-border/50 bg-popover/95 backdrop-blur-xl shadow-2xl shadow-black/10 dark:shadow-black/20 ring-1 ring-black/5 dark:ring-white/10" style={{ top: position.top, left: position.left }}>
|
|
{/* Header */}
|
|
<div className={cn('flex items-center gap-2 px-3 py-2.5 border-b border-border/50 bg-gradient-to-r from-muted/50 to-transparent', catConfig?.gradient)}>
|
|
<div className={cn('flex h-6 w-6 items-center justify-center rounded-md bg-muted border border-border/50', catConfig?.bgColor, catConfig?.borderColor)}>
|
|
<span className="text-xs font-semibold text-muted-foreground">@</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center gap-1.5 min-w-0">
|
|
{mentionState.hasColon ? (
|
|
<>
|
|
<span className={cn('text-xs font-medium px-1.5 py-0.5 rounded-md', catConfig?.bgColor, catConfig?.color)}>{mentionState.category}</span>
|
|
{mentionState.item && (<>
|
|
<span className="text-muted-foreground">/</span>
|
|
<span className="text-xs text-foreground font-medium truncate">{mentionState.item}</span>
|
|
</>)}
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Type to filter</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono">↑↓</kbd>
|
|
<span>navigate</span>
|
|
<span className="text-muted-foreground/50">|</span>
|
|
<kbd className="px-1.5 py-0.5 rounded bg-muted border border-border/50 font-mono">↵</kbd>
|
|
<span>select</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{suggestions.length > 0 ? (
|
|
<ScrollArea className="max-h-80">
|
|
<div className="py-1">
|
|
{suggestions.map((s, i) => s.type === 'category' ? (
|
|
<CategoryHeader key={s.label + i} suggestion={s} isSelected={i === selectedIndex} />
|
|
) : (
|
|
<div ref={el => { itemsRef.current[i] = el; }}>
|
|
<SuggestionItem key={s.mentionId || s.label + i} suggestion={s} isSelected={i === selectedIndex}
|
|
onSelect={() => doInsert(s)}
|
|
onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }}
|
|
searchTerm={mentionState.item} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
) : isLoading ? <LoadingSkeleton /> : <EmptyState loading={isLoading} />}
|
|
|
|
{/* Footer */}
|
|
{suggestions[selectedIndex]?.type === 'item' && (
|
|
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50 bg-muted/30">
|
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
<span className={cn('px-1.5 py-0.5 rounded-md font-medium', catConfig?.bgColor, catConfig?.color)}>
|
|
{suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].category : ''}
|
|
</span>
|
|
<span className="truncate max-w-[140px]">{suggestions[selectedIndex]?.type === 'item' ? suggestions[selectedIndex].label : ''}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Extract relevant CSS properties from computed styles for measurement clone */
|
|
function getComputedStyleStyles(styles: CSSStyleDeclaration): string {
|
|
const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'paddingLeft', 'paddingRight',
|
|
'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth',
|
|
'boxSizing', 'width', 'maxWidth', 'letterSpacing', 'tabSize'];
|
|
return props.map(p => `${p.replace(/([A-Z])/g, '-$1').toLowerCase()}:${styles.getPropertyValue(p)}`).join(';');
|
|
}
|