1/**
2 * Mention Utilities
3 *
4 * Consolidated constants, regex patterns, and pure functions for @mention handling.
5 * Used by chat-room.svelte, text-formatter.ts, and formatted-message.svelte.
6 */
7
8// ============ CONSTANTS ============
9
10export const MENTION_USERNAME_MAX_LENGTH = 32;
11
12export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/;
13
14export const MAX_MENTION_SUGGESTIONS = 6;
15
16export const MENTION_PICKER = {
17 ITEM_HEIGHT: 32,
18 PADDING: 12,
19 EMPTY_STATE_HEIGHT: 44,
20 HORIZONTAL_PADDING: 68,
21 MIN_WIDTH: 220,
22 OFFSET_GAP: 6,
23 EDGE_MARGIN: 8
24} as const;
25
26// ============ REGEX PATTERNS ============
27
28/**
29 * Regex for matching @mentions in plain text.
30 * Captures: $1 = prefix (space or start), $2 = username
31 */
32export const MENTION_REGEX_PLAIN = /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g;
33
34/**
35 * Regex for matching @mentions in HTML context (after sanitization).
36 * Handles cases where mentions appear after HTML tags like <br> or </p>.
37 * Captures: $1 = prefix (space, start, or >), $2 = username
38 */
39export const MENTION_REGEX_HTML = /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g;
40
41// ============ PURE UTILITY FUNCTIONS ============
42
43/**
44 * Checks if the given index is at the start of a word.
45 * A word starts at position 0 or after whitespace.
46 */
47export function isWordStart(value: string, index: number): boolean {
48 if (index <= 0) return true;
49 return /\s/.test(value[index - 1]);
50}
51
52/**
53 * Finds mention context at the cursor position.
54 * Returns the @ index and the partial query string, or null if no valid mention context.
55 */
56export function findMentionContext(
57 value: string,
58 cursorIndex: number
59): { atIndex: number; query: string } | null {
60 const beforeCursor = value.slice(0, cursorIndex);
61 const atIndex = beforeCursor.lastIndexOf('@');
62
63 if (atIndex === -1) return null;
64 if (!isWordStart(value, atIndex)) return null;
65
66 const query = value.slice(atIndex + 1, cursorIndex);
67
68 // Query cannot contain whitespace
69 if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null;
70
71 // Query cannot exceed max length
72 if (query.length > MENTION_USERNAME_MAX_LENGTH) return null;
73
74 // If there's a query, it must match the username pattern
75 // Empty query is allowed (shows all users when just typing @)
76 if (query && !USERNAME_PATTERN.test(query)) return null;
77
78 return { atIndex, query };
79}
80
81/**
82 * Highlights @mentions in plain text by wrapping them in spans.
83 * Text must be HTML-escaped before calling this function.
84 */
85export function highlightMentionsPlain(escapedText: string): string {
86 return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="mention-token">@$2</span>');
87}
88
89/**
90 * Highlights @mentions in HTML content (post-sanitization).
91 * Used in text-formatter.ts after rehype processing.
92 */
93export function highlightMentionsHtml(html: string): string {
94 return html.replace(MENTION_REGEX_HTML, '$1<span class="mention-token">@$2</span>');
95}
96
97/**
98 * Highlights @mentions for input field display (with different class).
99 */
100export function highlightMentionsInput(escapedText: string): string {
101 return escapedText.replace(MENTION_REGEX_PLAIN, '$1<span class="input-mention">@$2</span>');
102}
103
104/**
105 * Checks if content contains a mention of the given username.
106 * Case-insensitive by default.
107 */
108export function containsMention(
109 content: string,
110 username: string,
111 caseSensitive: boolean = false
112): boolean {
113 const escapedUsername = username.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
114 const flags = caseSensitive ? '' : 'i';
115 const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedUsername})(?=$|\\s|[^a-zA-Z0-9_-])`, flags);
116 return mentionRegex.test(content);
117}
118
119/**
120 * Calculates the height of the mention picker based on suggestion count.
121 */
122export function calculatePickerHeight(suggestionCount: number): number {
123 if (suggestionCount === 0) {
124 return MENTION_PICKER.EMPTY_STATE_HEIGHT;
125 }
126 return (
127 Math.min(suggestionCount, MAX_MENTION_SUGGESTIONS) * MENTION_PICKER.ITEM_HEIGHT +
128 MENTION_PICKER.PADDING
129 );
130}
131
132/**
133 * Calculates the width of the mention picker based on content.
134 * @param maxNameWidth - Maximum width of username text in pixels
135 * @param statusWidth - Width of status text in pixels
136 */
137export function calculatePickerWidth(maxNameWidth: number, statusWidth: number): number {
138 return Math.max(
139 MENTION_PICKER.MIN_WIDTH,
140 Math.ceil(maxNameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING)
141 );
142}