gitdataai/src/components/room/MentionPopover.tsx
ZhenYi 989a4117db fix(frontend): fix useMemo deps syntax and replace stale ref reads in render body
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.
2026-04-18 11:14:10 +08:00

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(';');
}