import * as React from 'react'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { cn } from '@renderer/lib/utils'; import { AutoResizeTextarea } from './auto-resize-textarea'; import { MentionSuggestionList } from './MentionSuggestionList'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { MentionSuggestion } from '@renderer/types/mention'; // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) // --------------------------------------------------------------------------- interface TextSegment { type: 'text'; value: string; } interface MentionSegment { type: 'mention'; value: string; suggestion: MentionSuggestion; } type Segment = TextSegment | MentionSegment; /** * Splits text into alternating text / @mention segments. * * Rules: * - `@` must be at start of text or preceded by whitespace * - The name after `@` must exactly match a suggestion name (case-insensitive) * - The character after the name must be whitespace, punctuation, or end-of-text * - Longer names are tried first (greedy matching) */ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): Segment[] { if (!text || suggestions.length === 0) return [{ type: 'text', value: text }]; // Sort by name length descending for greedy matching const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length); const segments: Segment[] = []; let i = 0; let textStart = 0; while (i < text.length) { if (text[i] !== '@') { i++; continue; } // @ must be at start or after whitespace if (i > 0) { const ch = text[i - 1]; if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') { i++; continue; } } let matched = false; for (const suggestion of sorted) { const end = i + 1 + suggestion.name.length; if (end > text.length) continue; if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue; // Character after name must be boundary if (end < text.length) { const after = text[end]; // eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue; } // Flush preceding text if (i > textStart) { segments.push({ type: 'text', value: text.slice(textStart, i) }); } segments.push({ type: 'mention', value: text.slice(i, end), suggestion }); i = end; textStart = i; matched = true; break; } if (!matched) i++; } if (textStart < text.length) { segments.push({ type: 'text', value: text.slice(textStart) }); } return segments; } // Default fallback color for mentions without a team color const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_TEXT = '#60a5fa'; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface MentionableTextareaProps extends Omit< AutoResizeTextareaProps, 'value' | 'onChange' | 'onKeyDown' | 'onSelect' > { value: string; onValueChange: (v: string) => void; suggestions: MentionSuggestion[]; hintText?: string; showHint?: boolean; /** Content rendered at the right side of the footer row (e.g. "Draft saved") */ footerRight?: React.ReactNode; /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ cornerAction?: React.ReactNode; } export const MentionableTextarea = React.forwardRef( ( { value, onValueChange, suggestions, hintText = 'Use @ to mention team members', showHint = true, footerRight, cornerAction, style, className, ...textareaProps }, forwardedRef ) => { const internalRef = React.useRef(null); const backdropRef = React.useRef(null); const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { internalRef.current = node; if (typeof forwardedRef === 'function') { forwardedRef(node); } else if (forwardedRef) { // eslint-disable-next-line no-param-reassign -- ref merging requires mutation forwardedRef.current = node; } }, [forwardedRef] ); const { isOpen, query, filteredSuggestions, selectedIndex, dropdownPosition, selectSuggestion, handleKeyDown, handleChange, handleSelect, } = useMentionDetection({ suggestions, value, onValueChange, textareaRef: internalRef, }); // Sync backdrop font with textarea computed font to prevent caret drift. // Chromium UA stylesheet may apply different font-family / letter-spacing // to