Merge pull request #79 from kitpdr/feat/mention-shortcut
feat(chat): add @mention autocomplete with highlighting and unread tracking
10 files · +1229 -45
M src/lib/components/aim-login.svelte +10 -4
| @@ -15,9 +15,15 @@ | |||
| 15 | 15 | } from '../validation/password'; | |
| 16 | 16 | import { z } from 'zod'; | |
| 17 | 17 | ||
| 18 | - | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | |
| 19 | - | ||
| 20 | - | let activeTab = $state(initialTab); | |
| 18 | + | let { | |
| 19 | + | showAuth = $bindable(), | |
| 20 | + | onLoginSuccess, | |
| 21 | + | activeTab = $bindable<'signin' | 'signup'>('signin') | |
| 22 | + | } = $props<{ | |
| 23 | + | showAuth?: boolean; | |
| 24 | + | onLoginSuccess?: () => void; | |
| 25 | + | activeTab?: 'signin' | 'signup'; | |
| 26 | + | }>(); | |
| 21 | 27 | ||
| 22 | 28 | // Sign In state | |
| 23 | 29 | let siUsername = $state(''); | |
| @@ -229,7 +235,7 @@ | |||
| 229 | 235 | } | |
| 230 | 236 | } | |
| 231 | 237 | ||
| 232 | - | function switchTab(tab: string) { | |
| 238 | + | function switchTab(tab: 'signin' | 'signup') { | |
| 233 | 239 | activeTab = tab; | |
| 234 | 240 | error = ''; | |
| 235 | 241 | signupError = ''; | |
| @@ -15,9 +15,15 @@ | |||||
| 15 | } from '../validation/password'; | 15 | } from '../validation/password'; | ||
| 16 | import { z } from 'zod'; | 16 | import { z } from 'zod'; | ||
| 17 | 17 | ||||
| 18 | - | let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | 18 | + | let { |
| 19 | - | 19 | + | showAuth = $bindable(), | |
| 20 | - | let activeTab = $state(initialTab); | 20 | + | onLoginSuccess, |
| 21 | + | activeTab = $bindable<'signin' | 'signup'>('signin') | |||
| 22 | + | } = $props<{ | |||
| 23 | + | showAuth?: boolean; | |||
| 24 | + | onLoginSuccess?: () => void; | |||
| 25 | + | activeTab?: 'signin' | 'signup'; | |||
| 26 | + | }>(); | |||
| 21 | 27 | ||||
| 22 | // Sign In state | 28 | // Sign In state | ||
| 23 | let siUsername = $state(''); | 29 | let siUsername = $state(''); | ||
| @@ -229,7 +235,7 @@ | |||||
| 229 | } | 235 | } | ||
| 230 | } | 236 | } | ||
| 231 | 237 | ||||
| 232 | - | function switchTab(tab: string) { | 238 | + | function switchTab(tab: 'signin' | 'signup') { |
| 233 | activeTab = tab; | 239 | activeTab = tab; | ||
| 234 | error = ''; | 240 | error = ''; | ||
| 235 | signupError = ''; | 241 | signupError = ''; | ||
M src/lib/components/chat-room.svelte +580 -33
| @@ -4,7 +4,8 @@ | |||
| 4 | 4 | import { api } from '../../convex/_generated/api'; | |
| 5 | 5 | import type { Id } from '../../convex/_generated/dataModel'; | |
| 6 | 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 7 | - | import { onMount } from 'svelte'; | |
| 7 | + | import { onMount, tick } from 'svelte'; | |
| 8 | + | import { SvelteSet } from 'svelte/reactivity'; | |
| 8 | 9 | import { browser } from '$app/environment'; | |
| 9 | 10 | import { draggable } from '$lib/actions/draggable'; | |
| 10 | 11 | import { resizable } from '$lib/actions/resizable'; | |
| @@ -16,11 +17,17 @@ | |||
| 16 | 17 | import AimLogin from './aim-login.svelte'; | |
| 17 | 18 | import FormattedMessage from './formatted-message.svelte'; | |
| 18 | 19 | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | |
| 20 | + | import MentionPicker from './mention-picker.svelte'; | |
| 19 | 21 | import { | |
| 20 | - | DEFAULT_TEXT_STYLE, | |
| 21 | - | type TextStyle, | |
| 22 | - | generateInputCSSStyle | |
| 23 | - | } from '../types/text-formatting'; | |
| 22 | + | MAX_MENTION_SUGGESTIONS, | |
| 23 | + | MENTION_PICKER, | |
| 24 | + | findMentionContext, | |
| 25 | + | highlightMentionsInput, | |
| 26 | + | containsMention, | |
| 27 | + | calculatePickerHeight, | |
| 28 | + | calculatePickerWidth | |
| 29 | + | } from '../utils/mention'; | |
| 30 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting'; | |
| 24 | 31 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | |
| 25 | 32 | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | |
| 26 | 33 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| @@ -146,12 +153,34 @@ | |||
| 146 | 153 | ||
| 147 | 154 | let currentMessage = $state(''); | |
| 148 | 155 | let inputScrollLeft = $state(0); | |
| 156 | + | // Initialize text style with prop value (intentionally capturing initial value only) | |
| 149 | 157 | let currentTextStyle = $state<TextStyle>({ | |
| 150 | 158 | ...DEFAULT_TEXT_STYLE, | |
| 151 | 159 | ...initialTextStyle, | |
| 152 | 160 | color: initialTextStyle.color || '#000000' | |
| 153 | 161 | }); | |
| 154 | 162 | ||
| 163 | + | // ============ MENTION STATE ============ | |
| 164 | + | ||
| 165 | + | let messageInput = $state<HTMLInputElement | null>(null); | |
| 166 | + | let mentionOpen = $state(false); | |
| 167 | + | let mentionQuery = $state(''); | |
| 168 | + | let mentionAnchorIndex = $state<number | null>(null); | |
| 169 | + | let mentionSelectedIndex = $state(0); | |
| 170 | + | let mentionObservedIds = $state<Set<string>>(new Set()); | |
| 171 | + | let mentionMessageIds = $state<Set<string>>(new Set()); | |
| 172 | + | let mentionUnreadCount = $state(0); | |
| 173 | + | let mentionDismissKey = $state<string | null>(null); | |
| 174 | + | let mentionObserver: IntersectionObserver | null = null; | |
| 175 | + | ||
| 176 | + | let baseTitle = $state(''); | |
| 177 | + | const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`; | |
| 178 | + | let mentionActiveId = $state<string | null>(null); | |
| 179 | + | let chatArea = $state<HTMLDivElement | null>(null); | |
| 180 | + | let mentionListStyle = $state(''); | |
| 181 | + | let mentionListInside = $state(true); | |
| 182 | + | let mentionMeasureContext: CanvasRenderingContext2D | null = null; | |
| 183 | + | ||
| 155 | 184 | // ============ RATE LIMITING STATE ============ | |
| 156 | 185 | ||
| 157 | 186 | let cooldownEndTime = $state<number | null>(null); | |
| @@ -198,19 +227,55 @@ | |||
| 198 | 227 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |
| 199 | 228 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |
| 200 | 229 | ||
| 230 | + | const mentionableUsers = $derived( | |
| 231 | + | onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id) | |
| 232 | + | ); | |
| 233 | + | ||
| 234 | + | // Filter users for mention autocomplete | |
| 235 | + | // Empty query (just "@") shows all users to let user browse the list | |
| 236 | + | const mentionSuggestions = $derived.by<SafeUser[]>(() => { | |
| 237 | + | if (!mentionOpen) return []; | |
| 238 | + | const query = mentionQuery.trim().toLowerCase(); | |
| 239 | + | const candidates = query | |
| 240 | + | ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query)) | |
| 241 | + | : mentionableUsers; | |
| 242 | + | return candidates.slice(0, MAX_MENTION_SUGGESTIONS); | |
| 243 | + | }); | |
| 244 | + | ||
| 245 | + | // Format input message with mention highlighting (for overlay display) | |
| 246 | + | const formattedInputMessage = $derived.by(() => { | |
| 247 | + | if (!currentMessage) return ''; | |
| 248 | + | // Escape HTML entities first | |
| 249 | + | const escaped = currentMessage | |
| 250 | + | .replace(/&/g, '&') | |
| 251 | + | .replace(/</g, '<') | |
| 252 | + | .replace(/>/g, '>'); | |
| 253 | + | // Apply mention styling using shared utility | |
| 254 | + | return highlightMentionsInput(escaped); | |
| 255 | + | }); | |
| 256 | + | ||
| 257 | + | // mentionUnreadCount is managed in state updates | |
| 258 | + | const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => { | |
| 259 | + | const highlights: Record<string, boolean> = {}; | |
| 260 | + | if (!currentUser) return highlights; | |
| 261 | + | for (const message of visibleMessages) { | |
| 262 | + | highlights[message.id] = shouldHighlightMention(message); | |
| 263 | + | } | |
| 264 | + | return highlights; | |
| 265 | + | }); | |
| 266 | + | ||
| 201 | 267 | // ============ EFFECTS ============ | |
| 202 | 268 | ||
| 203 | 269 | // Auto-scroll to bottom when new messages arrive | |
| 204 | 270 | $effect(() => { | |
| 205 | 271 | if (messages.length > lastMessageCount && messages.length > 0) { | |
| 206 | 272 | lastMessageCount = messages.length; | |
| 207 | - | if (browser) { | |
| 208 | - | const chatArea = document.querySelector('.chat-area'); | |
| 209 | - | if (chatArea) { | |
| 210 | - | setTimeout(() => { | |
| 273 | + | if (browser && chatArea) { | |
| 274 | + | tick().then(() => { | |
| 275 | + | if (chatArea) { | |
| 211 | 276 | chatArea.scrollTop = chatArea.scrollHeight; | |
| 212 | - | }, 0); | |
| 213 | - | } | |
| 277 | + | } | |
| 278 | + | }); | |
| 214 | 279 | } | |
| 215 | 280 | } | |
| 216 | 281 | }); | |
| @@ -222,6 +287,173 @@ | |||
| 222 | 287 | } | |
| 223 | 288 | }); | |
| 224 | 289 | ||
| 290 | + | $effect(() => { | |
| 291 | + | if (!browser) return; | |
| 292 | + | if (!baseTitle) baseTitle = document.title; | |
| 293 | + | const totalMentions = chatState.getMentionTotal(); | |
| 294 | + | if (totalMentions > 0) { | |
| 295 | + | document.title = `(${totalMentions}) ${baseTitle}`; | |
| 296 | + | } else { | |
| 297 | + | document.title = baseTitle; | |
| 298 | + | } | |
| 299 | + | }); | |
| 300 | + | ||
| 301 | + | $effect(() => { | |
| 302 | + | chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount); | |
| 303 | + | }); | |
| 304 | + | ||
| 305 | + | $effect(() => { | |
| 306 | + | if (!browser || !currentUser) { | |
| 307 | + | mentionObservedIds = new SvelteSet(); | |
| 308 | + | mentionMessageIds = new SvelteSet(); | |
| 309 | + | if (mentionUnreadCount !== 0) { | |
| 310 | + | mentionUnreadCount = 0; | |
| 311 | + | } | |
| 312 | + | // mention observer reset | |
| 313 | + | if (mentionObserver) { | |
| 314 | + | mentionObserver.disconnect(); | |
| 315 | + | mentionObserver = null; | |
| 316 | + | } | |
| 317 | + | return; | |
| 318 | + | } | |
| 319 | + | const mentionIds = new SvelteSet<string>(); | |
| 320 | + | for (const message of visibleMessages) { | |
| 321 | + | if (shouldHighlightMention(message)) { | |
| 322 | + | mentionIds.add(message.id); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | const nextMentionIds = mentionIds; | |
| 326 | + | if (!setsEqual(nextMentionIds, mentionMessageIds)) { | |
| 327 | + | mentionMessageIds = nextMentionIds; | |
| 328 | + | } | |
| 329 | + | const nextUnreadCount = Array.from(nextMentionIds).filter( | |
| 330 | + | (messageId) => !mentionObservedIds.has(messageId) | |
| 331 | + | ).length; | |
| 332 | + | // mentionObservedIds is a local Set to avoid effect loops | |
| 333 | + | if (mentionUnreadCount !== nextUnreadCount) { | |
| 334 | + | mentionUnreadCount = nextUnreadCount; | |
| 335 | + | } | |
| 336 | + | }); | |
| 337 | + | ||
| 338 | + | $effect(() => { | |
| 339 | + | const currentMentionIds = mentionMessageIds; | |
| 340 | + | if (!browser || !chatArea || !currentUser) return; | |
| 341 | + | if (mentionObserver) { | |
| 342 | + | mentionObserver.disconnect(); | |
| 343 | + | } | |
| 344 | + | mentionObserver = new IntersectionObserver( | |
| 345 | + | (entries) => { | |
| 346 | + | const updatedObserved = new SvelteSet(mentionObservedIds); | |
| 347 | + | let changed = false; | |
| 348 | + | const currentObserved = mentionObservedIds; | |
| 349 | + | const currentUnread = mentionUnreadCount; | |
| 350 | + | for (const entry of entries) { | |
| 351 | + | const target = entry.target as HTMLElement; | |
| 352 | + | const messageId = target.dataset.messageId; | |
| 353 | + | if (!messageId || !currentMentionIds.has(messageId)) continue; | |
| 354 | + | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |
| 355 | + | updatedObserved.add(messageId); | |
| 356 | + | changed = true; | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |
| 360 | + | mentionObservedIds = updatedObserved; | |
| 361 | + | const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size); | |
| 362 | + | if (currentUnread !== nextUnread) { | |
| 363 | + | mentionUnreadCount = nextUnread; | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | }, | |
| 367 | + | { root: chatArea, threshold: 0.6 } | |
| 368 | + | ); | |
| 369 | + | const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]')); | |
| 370 | + | for (const node of nodes) { | |
| 371 | + | const messageId = node.dataset.messageId; | |
| 372 | + | if (messageId && currentMentionIds.has(messageId)) { | |
| 373 | + | mentionObserver.observe(node); | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | // only observe mention messages | |
| 377 | + | // mention observer reset | |
| 378 | + | return () => { | |
| 379 | + | mentionObserver?.disconnect(); | |
| 380 | + | }; | |
| 381 | + | }); | |
| 382 | + | ||
| 383 | + | $effect(() => { | |
| 384 | + | if (!mentionOpen || mentionSuggestions.length === 0) { | |
| 385 | + | if (mentionActiveId !== null) { | |
| 386 | + | mentionActiveId = null; | |
| 387 | + | } | |
| 388 | + | return; | |
| 389 | + | } | |
| 390 | + | const currentActiveId = mentionActiveId; | |
| 391 | + | let selectedIndex = mentionSelectedIndex; | |
| 392 | + | if (selectedIndex >= mentionSuggestions.length) { | |
| 393 | + | selectedIndex = 0; | |
| 394 | + | if (mentionSelectedIndex !== 0) { | |
| 395 | + | mentionSelectedIndex = 0; | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | const active = mentionSuggestions[selectedIndex]; | |
| 399 | + | const nextActiveId = active ? getMentionListItemId(active.id) : null; | |
| 400 | + | if (currentActiveId !== nextActiveId) { | |
| 401 | + | mentionActiveId = nextActiveId; | |
| 402 | + | } | |
| 403 | + | }); | |
| 404 | + | ||
| 405 | + | $effect(() => { | |
| 406 | + | if (!mentionOpen || !messageInput) { | |
| 407 | + | if (mentionListStyle) mentionListStyle = ''; | |
| 408 | + | return; | |
| 409 | + | } | |
| 410 | + | const currentListStyle = mentionListStyle; | |
| 411 | + | const currentInside = mentionListInside; | |
| 412 | + | const inputRect = messageInput.getBoundingClientRect(); | |
| 413 | + | const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect(); | |
| 414 | + | // Use shared utility for height calculation | |
| 415 | + | const listHeight = calculatePickerHeight(mentionSuggestions.length); | |
| 416 | + | const spaceBelow = containerRect | |
| 417 | + | ? containerRect.bottom - inputRect.bottom | |
| 418 | + | : window.innerHeight - inputRect.bottom; | |
| 419 | + | const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top; | |
| 420 | + | const showAbove = spaceBelow < listHeight && spaceAbove > listHeight; | |
| 421 | + | const maxNameWidth = mentionSuggestions.reduce((max, user) => { | |
| 422 | + | const width = measureMentionText(user.nickname, true, 13.6); | |
| 423 | + | return Math.max(max, width); | |
| 424 | + | }, 0); | |
| 425 | + | // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing) | |
| 426 | + | const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1; | |
| 427 | + | // Use shared utility for width calculation | |
| 428 | + | const listWidth = calculatePickerWidth(maxNameWidth, statusWidth); | |
| 429 | + | if (containerRect) { | |
| 430 | + | if (!currentInside) mentionListInside = true; | |
| 431 | + | const top = showAbove | |
| 432 | + | ? inputRect.top - containerRect.top - listHeight - MENTION_PICKER.OFFSET_GAP | |
| 433 | + | : inputRect.bottom - containerRect.top + MENTION_PICKER.OFFSET_GAP; | |
| 434 | + | const left = inputRect.left - containerRect.left; | |
| 435 | + | const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, left)}px; width: ${listWidth}px;`; | |
| 436 | + | if (currentListStyle !== nextStyle) { | |
| 437 | + | mentionListStyle = nextStyle; | |
| 438 | + | } | |
| 439 | + | } else { | |
| 440 | + | if (currentInside) mentionListInside = false; | |
| 441 | + | const top = showAbove | |
| 442 | + | ? inputRect.top - listHeight - MENTION_PICKER.OFFSET_GAP | |
| 443 | + | : inputRect.bottom + MENTION_PICKER.OFFSET_GAP; | |
| 444 | + | const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, inputRect.left)}px; width: ${listWidth}px;`; | |
| 445 | + | if (currentListStyle !== nextStyle) { | |
| 446 | + | mentionListStyle = nextStyle; | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | if (mentionSuggestions.length === 0 && mentionActiveId !== null) { | |
| 450 | + | mentionActiveId = null; | |
| 451 | + | } | |
| 452 | + | if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) { | |
| 453 | + | mentionSelectedIndex = 0; | |
| 454 | + | } | |
| 455 | + | }); | |
| 456 | + | ||
| 225 | 457 | // ============ HELPERS ============ | |
| 226 | 458 | ||
| 227 | 459 | function resolveUser(senderId: string): SafeUser { | |
| @@ -253,6 +485,143 @@ | |||
| 253 | 485 | ||
| 254 | 486 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 255 | 487 | ||
| 488 | + | function setsEqual(a: Set<string>, b: Set<string>) { | |
| 489 | + | if (a.size !== b.size) return false; | |
| 490 | + | for (const value of a) { | |
| 491 | + | if (!b.has(value)) return false; | |
| 492 | + | } | |
| 493 | + | return true; | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) { | |
| 497 | + | if (!mentionMeasureContext) { | |
| 498 | + | const canvas = document.createElement('canvas'); | |
| 499 | + | mentionMeasureContext = canvas.getContext('2d'); | |
| 500 | + | } | |
| 501 | + | if (!mentionMeasureContext) return 0; | |
| 502 | + | mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`; | |
| 503 | + | return mentionMeasureContext.measureText(text).width; | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | function updateMentionState(input: HTMLInputElement) { | |
| 507 | + | if (!currentUser) { | |
| 508 | + | mentionOpen = false; | |
| 509 | + | return; | |
| 510 | + | } | |
| 511 | + | const cursorIndex = input.selectionStart ?? input.value.length; | |
| 512 | + | const context = findMentionContext(input.value, cursorIndex); | |
| 513 | + | if (!context) { | |
| 514 | + | closeMentionPicker(); | |
| 515 | + | return; | |
| 516 | + | } | |
| 517 | + | const mentionKey = `${context.atIndex}:${context.query}`; | |
| 518 | + | if (mentionDismissKey === mentionKey) { | |
| 519 | + | return; | |
| 520 | + | } | |
| 521 | + | const wasOpen = mentionOpen; | |
| 522 | + | const previousQuery = mentionQuery; | |
| 523 | + | const sameAnchor = mentionAnchorIndex === context.atIndex; | |
| 524 | + | mentionOpen = true; | |
| 525 | + | mentionQuery = context.query; | |
| 526 | + | mentionAnchorIndex = context.atIndex; | |
| 527 | + | mentionDismissKey = null; | |
| 528 | + | if (!wasOpen || !sameAnchor || previousQuery !== context.query) { | |
| 529 | + | mentionSelectedIndex = 0; | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | function registerMentionNode(node: HTMLElement) { | |
| 534 | + | if (!node) return; | |
| 535 | + | const messageId = node.dataset.messageId; | |
| 536 | + | if (!messageId) return; | |
| 537 | + | if (mentionObserver) { | |
| 538 | + | mentionObserver.observe(node); | |
| 539 | + | } | |
| 540 | + | // Return destroy method to properly cleanup when node is removed | |
| 541 | + | return { | |
| 542 | + | destroy() { | |
| 543 | + | if (mentionObserver) { | |
| 544 | + | mentionObserver.unobserve(node); | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | }; | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | function closeMentionPicker() { | |
| 551 | + | mentionOpen = false; | |
| 552 | + | mentionQuery = ''; | |
| 553 | + | mentionAnchorIndex = null; | |
| 554 | + | mentionSelectedIndex = 0; | |
| 555 | + | mentionActiveId = null; | |
| 556 | + | mentionDismissKey = null; | |
| 557 | + | if (messageInput) { | |
| 558 | + | messageInput.focus(); | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | function clampMentionIndex(nextIndex: number) { | |
| 563 | + | if (mentionSuggestions.length === 0) return 0; | |
| 564 | + | const maxIndex = mentionSuggestions.length - 1; | |
| 565 | + | if (nextIndex < 0) return maxIndex; | |
| 566 | + | if (nextIndex > maxIndex) return 0; | |
| 567 | + | return nextIndex; | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | function applyMentionSelection(user: SafeUser) { | |
| 571 | + | if (!messageInput || mentionAnchorIndex === null) return; | |
| 572 | + | const selectionStart = messageInput.selectionStart ?? currentMessage.length; | |
| 573 | + | const before = currentMessage.slice(0, mentionAnchorIndex); | |
| 574 | + | const after = currentMessage.slice(selectionStart); | |
| 575 | + | const mentionText = `@${user.nickname} `; | |
| 576 | + | currentMessage = `${before}${mentionText}${after}`; | |
| 577 | + | void tick().then(() => { | |
| 578 | + | if (!messageInput) return; | |
| 579 | + | const cursor = before.length + mentionText.length; | |
| 580 | + | messageInput.focus(); | |
| 581 | + | messageInput.setSelectionRange(cursor, cursor); | |
| 582 | + | }); | |
| 583 | + | closeMentionPicker(); | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | function handleMentionKeydown(event: KeyboardEvent) { | |
| 587 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |
| 588 | + | if (event.key === 'ArrowDown') { | |
| 589 | + | event.preventDefault(); | |
| 590 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1); | |
| 591 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 592 | + | return; | |
| 593 | + | } | |
| 594 | + | if (event.key === 'ArrowUp') { | |
| 595 | + | event.preventDefault(); | |
| 596 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1); | |
| 597 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 598 | + | return; | |
| 599 | + | } | |
| 600 | + | if (event.key === 'Enter' || event.key === 'Tab') { | |
| 601 | + | event.preventDefault(); | |
| 602 | + | const choice = mentionSuggestions[mentionSelectedIndex]; | |
| 603 | + | if (choice) applyMentionSelection(choice); | |
| 604 | + | return; | |
| 605 | + | } | |
| 606 | + | if (event.key === 'Escape') { | |
| 607 | + | event.preventDefault(); | |
| 608 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |
| 609 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |
| 610 | + | } | |
| 611 | + | closeMentionPicker(); | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | function getMentionListItemId(userId: string) { | |
| 616 | + | return `${mentionListBoxId}-${userId}`; | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | function scrollMentionOptionIntoView() { | |
| 620 | + | if (!mentionActiveId) return; | |
| 621 | + | const option = document.getElementById(mentionActiveId); | |
| 622 | + | option?.scrollIntoView({ block: 'nearest' }); | |
| 623 | + | } | |
| 624 | + | ||
| 256 | 625 | function updateCooldownProgress() { | |
| 257 | 626 | if (!cooldownEndTime) return; | |
| 258 | 627 | const now = Date.now(); | |
| @@ -282,6 +651,12 @@ | |||
| 282 | 651 | showChatRoom = false; | |
| 283 | 652 | } | |
| 284 | 653 | ||
| 654 | + | function shouldHighlightMention(message: EnrichedMessage) { | |
| 655 | + | if (!currentUser) return false; | |
| 656 | + | if (message.senderId === currentUser.id) return false; | |
| 657 | + | return containsMention(message.content, currentUser.nickname); | |
| 658 | + | } | |
| 659 | + | ||
| 285 | 660 | function handleInputChange(e: Event) { | |
| 286 | 661 | const input = e.target as HTMLInputElement; | |
| 287 | 662 | if (input.value.length > MAX_MESSAGE_LENGTH) { | |
| @@ -289,12 +664,51 @@ | |||
| 289 | 664 | currentMessage = input.value; | |
| 290 | 665 | } | |
| 291 | 666 | inputScrollLeft = input.scrollLeft; | |
| 667 | + | updateMentionState(input); | |
| 668 | + | if (!mentionOpen && mentionQuery) { | |
| 669 | + | mentionQuery = ''; | |
| 670 | + | } | |
| 292 | 671 | } | |
| 293 | 672 | ||
| 294 | 673 | function handleInputScroll(e: Event) { | |
| 295 | 674 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | |
| 296 | 675 | } | |
| 297 | 676 | ||
| 677 | + | function handleInputFocus() { | |
| 678 | + | if (!messageInput) return; | |
| 679 | + | updateMentionState(messageInput); | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | function handleInputClick() { | |
| 683 | + | if (!messageInput) return; | |
| 684 | + | updateMentionState(messageInput); | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | function handleInputKeydown(event: KeyboardEvent) { | |
| 688 | + | if (!messageInput) return; | |
| 689 | + | if (event.key === 'Escape') { | |
| 690 | + | event.preventDefault(); | |
| 691 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |
| 692 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |
| 693 | + | } | |
| 694 | + | closeMentionPicker(); | |
| 695 | + | return; | |
| 696 | + | } | |
| 697 | + | if (mentionOpen) { | |
| 698 | + | handleMentionKeydown(event); | |
| 699 | + | return; | |
| 700 | + | } | |
| 701 | + | if (event.key === 'Enter') { | |
| 702 | + | event.preventDefault(); | |
| 703 | + | void handleSubmit(); | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | function handleInputKeyup() { | |
| 708 | + | if (!messageInput) return; | |
| 709 | + | updateMentionState(messageInput); | |
| 710 | + | } | |
| 711 | + | ||
| 298 | 712 | async function handleSubmit() { | |
| 299 | 713 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | |
| 300 | 714 | ||
| @@ -520,6 +934,8 @@ | |||
| 520 | 934 | return () => { | |
| 521 | 935 | window.removeEventListener('resize', handleResize); | |
| 522 | 936 | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 937 | + | // Clean up canvas context used for text measurement | |
| 938 | + | mentionMeasureContext = null; | |
| 523 | 939 | }; | |
| 524 | 940 | }); | |
| 525 | 941 | ||
| @@ -537,6 +953,14 @@ | |||
| 537 | 953 | isCentered | |
| 538 | 954 | }); | |
| 539 | 955 | }); | |
| 956 | + | ||
| 957 | + | // Scroll selected mention option into view when selection changes | |
| 958 | + | $effect(() => { | |
| 959 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |
| 960 | + | // Track mentionSelectedIndex to trigger scroll on keyboard navigation | |
| 961 | + | void mentionSelectedIndex; | |
| 962 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 963 | + | }); | |
| 540 | 964 | </script> | |
| 541 | 965 | ||
| 542 | 966 | {#if showChatRoom} | |
| @@ -567,7 +991,8 @@ | |||
| 567 | 991 | class="title-bar-text" | |
| 568 | 992 | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | |
| 569 | 993 | > | |
| 570 | - | Pdr Aim {#if currentUser} | |
| 994 | + | Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount}) | |
| 995 | + | {/if}{#if currentUser} | |
| 571 | 996 | - {currentUser.nickname}{:else} | |
| 572 | 997 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | |
| 573 | 998 | {#if connectionError && !isMinimized} | |
| @@ -640,6 +1065,7 @@ | |||
| 640 | 1065 | class="sunken-panel chat-area" | |
| 641 | 1066 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | |
| 642 | 1067 | onscroll={(e) => handleScroll(e)} | |
| 1068 | + | bind:this={chatArea} | |
| 643 | 1069 | > | |
| 644 | 1070 | {#if isInitialLoading} | |
| 645 | 1071 | <div class="initial-loading"> | |
| @@ -659,7 +1085,18 @@ | |||
| 659 | 1085 | </div> | |
| 660 | 1086 | {/if} | |
| 661 | 1087 | {#each visibleMessages as message (message.id)} | |
| 662 | - | <div class="message {message.type} text"> | |
| 1088 | + | <div | |
| 1089 | + | class="message {message.type} text" | |
| 1090 | + | class:mention-highlight={hasMentionHighlight[message.id]} | |
| 1091 | + | data-message-id={message.id} | |
| 1092 | + | data-mention={hasMentionHighlight[message.id] | |
| 1093 | + | ? `Mention de @${currentUser?.nickname ?? ''}` | |
| 1094 | + | : undefined} | |
| 1095 | + | aria-label={hasMentionHighlight[message.id] | |
| 1096 | + | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |
| 1097 | + | : undefined} | |
| 1098 | + | use:registerMentionNode | |
| 1099 | + | > | |
| 663 | 1100 | {#if message.type === 'emote'} | |
| 664 | 1101 | <span class="emote-text"> | |
| 665 | 1102 | <Tooltip | |
| @@ -737,8 +1174,11 @@ | |||
| 737 | 1174 | -webkit-text-fill-color: transparent; | |
| 738 | 1175 | background-clip: text; | |
| 739 | 1176 | " | |
| 740 | - | aria-hidden="true">{currentMessage}</span | |
| 1177 | + | aria-hidden="true" | |
| 741 | 1178 | > | |
| 1179 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | |
| 1180 | + | {@html formattedInputMessage} | |
| 1181 | + | </span> | |
| 742 | 1182 | <input | |
| 743 | 1183 | type="text" | |
| 744 | 1184 | bind:value={currentMessage} | |
| @@ -753,28 +1193,86 @@ | |||
| 753 | 1193 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 754 | 1194 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 755 | 1195 | : ''}" | |
| 756 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 1196 | + | onkeydown={handleInputKeydown} | |
| 1197 | + | onkeyup={handleInputKeyup} | |
| 757 | 1198 | oninput={handleInputChange} | |
| 758 | 1199 | onscroll={handleInputScroll} | |
| 1200 | + | onfocus={handleInputFocus} | |
| 1201 | + | onclick={handleInputClick} | |
| 1202 | + | bind:this={messageInput} | |
| 759 | 1203 | placeholder={cooldownEndTime | |
| 760 | 1204 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 761 | 1205 | : 'Écrivez un message...'} | |
| 762 | 1206 | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 1207 | + | role="combobox" | |
| 1208 | + | aria-autocomplete="list" | |
| 1209 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |
| 1210 | + | aria-expanded={mentionOpen} | |
| 763 | 1211 | /> | |
| 764 | 1212 | </div> | |
| 765 | 1213 | {:else} | |
| 766 | - | <input | |
| 767 | - | type="text" | |
| 768 | - | bind:value={currentMessage} | |
| 769 | - | maxlength={MAX_MESSAGE_LENGTH} | |
| 770 | - | class="styled-input retro-font-{currentTextStyle.fontFamily}" | |
| 771 | - | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | |
| 772 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 773 | - | placeholder={cooldownEndTime | |
| 774 | - | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 775 | - | : 'Écrivez un message...'} | |
| 776 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 777 | - | /> | |
| 1214 | + | <div | |
| 1215 | + | class="mention-input-wrapper" | |
| 1216 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" | |
| 1217 | + | > | |
| 1218 | + | <!-- Text overlay with mention highlighting --> | |
| 1219 | + | <span | |
| 1220 | + | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" | |
| 1221 | + | style=" | |
| 1222 | + | position: absolute; | |
| 1223 | + | left: 3px; | |
| 1224 | + | top: 50%; | |
| 1225 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); | |
| 1226 | + | pointer-events: none; | |
| 1227 | + | white-space: nowrap; | |
| 1228 | + | font-size: {currentTextStyle.fontSize}px; | |
| 1229 | + | color: {currentTextStyle.color || '#000000'}; | |
| 1230 | + | {currentTextStyle.bold | |
| 1231 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1232 | + | ? 'font-weight: 200;' | |
| 1233 | + | : 'font-weight: 700;' | |
| 1234 | + | : ''} | |
| 1235 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |
| 1236 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 1237 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 1238 | + | : ''} | |
| 1239 | + | " | |
| 1240 | + | aria-hidden="true" | |
| 1241 | + | > | |
| 1242 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | |
| 1243 | + | {@html formattedInputMessage} | |
| 1244 | + | </span> | |
| 1245 | + | <input | |
| 1246 | + | type="text" | |
| 1247 | + | bind:value={currentMessage} | |
| 1248 | + | maxlength={MAX_MESSAGE_LENGTH} | |
| 1249 | + | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" | |
| 1250 | + | style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold | |
| 1251 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1252 | + | ? 'font-weight: 200;' | |
| 1253 | + | : 'font-weight: 700;' | |
| 1254 | + | : ''} {currentTextStyle.italic | |
| 1255 | + | ? 'font-style: italic;' | |
| 1256 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 1257 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 1258 | + | : ''} caret-color: {currentTextStyle.color || 'black'};" | |
| 1259 | + | onkeydown={handleInputKeydown} | |
| 1260 | + | onkeyup={handleInputKeyup} | |
| 1261 | + | oninput={handleInputChange} | |
| 1262 | + | onscroll={handleInputScroll} | |
| 1263 | + | onfocus={handleInputFocus} | |
| 1264 | + | onclick={handleInputClick} | |
| 1265 | + | bind:this={messageInput} | |
| 1266 | + | placeholder={cooldownEndTime | |
| 1267 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 1268 | + | : 'Écrivez un message...'} | |
| 1269 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 1270 | + | role="combobox" | |
| 1271 | + | aria-autocomplete="list" | |
| 1272 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |
| 1273 | + | aria-expanded={mentionOpen} | |
| 1274 | + | /> | |
| 1275 | + | </div> | |
| 778 | 1276 | {/if} | |
| 779 | 1277 | <LoadingButton | |
| 780 | 1278 | onclick={handleSubmit} | |
| @@ -788,6 +1286,18 @@ | |||
| 788 | 1286 | </span> | |
| 789 | 1287 | {/if} | |
| 790 | 1288 | </div> | |
| 1289 | + | {#if mentionOpen} | |
| 1290 | + | <MentionPicker | |
| 1291 | + | suggestions={mentionSuggestions} | |
| 1292 | + | selectedIndex={mentionSelectedIndex} | |
| 1293 | + | activeId={mentionActiveId} | |
| 1294 | + | listBoxId={mentionListBoxId} | |
| 1295 | + | listStyle={mentionListStyle} | |
| 1296 | + | isPortal={!mentionListInside} | |
| 1297 | + | onSelect={applyMentionSelection} | |
| 1298 | + | /> | |
| 1299 | + | {/if} | |
| 1300 | + | ||
| 791 | 1301 | {#if cooldownEndTime} | |
| 792 | 1302 | <div | |
| 793 | 1303 | class="cooldown-progress" | |
| @@ -936,6 +1446,41 @@ | |||
| 936 | 1446 | color: #666; | |
| 937 | 1447 | } | |
| 938 | 1448 | ||
| 1449 | + | .message-content :global(.mention-token) { | |
| 1450 | + | color: #1e2a72; | |
| 1451 | + | font-weight: bold; | |
| 1452 | + | } | |
| 1453 | + | ||
| 1454 | + | /* Input mention highlighting */ | |
| 1455 | + | .mention-input-wrapper, | |
| 1456 | + | .gradient-input-wrapper { | |
| 1457 | + | flex: 1; | |
| 1458 | + | position: relative; | |
| 1459 | + | background: white; | |
| 1460 | + | overflow: hidden; | |
| 1461 | + | } | |
| 1462 | + | ||
| 1463 | + | .mention-text-overlay, | |
| 1464 | + | .gradient-text-overlay { | |
| 1465 | + | position: absolute; | |
| 1466 | + | left: 3px; | |
| 1467 | + | top: 50%; | |
| 1468 | + | pointer-events: none; | |
| 1469 | + | white-space: nowrap; | |
| 1470 | + | } | |
| 1471 | + | ||
| 1472 | + | .mention-text-overlay :global(.input-mention), | |
| 1473 | + | .gradient-text-overlay :global(.input-mention) { | |
| 1474 | + | color: #1e2a72; | |
| 1475 | + | -webkit-text-fill-color: #1e2a72; | |
| 1476 | + | } | |
| 1477 | + | ||
| 1478 | + | /* Force input text to be transparent when using overlay */ | |
| 1479 | + | .mention-overlay-input { | |
| 1480 | + | color: transparent !important; | |
| 1481 | + | -webkit-text-fill-color: transparent !important; | |
| 1482 | + | } | |
| 1483 | + | ||
| 939 | 1484 | .section-header { | |
| 940 | 1485 | margin: 0 0 0.25rem 0; | |
| 941 | 1486 | font-weight: bold; | |
| @@ -1000,8 +1545,7 @@ | |||
| 1000 | 1545 | z-index: 2; | |
| 1001 | 1546 | } | |
| 1002 | 1547 | ||
| 1003 | - | .input-container input:disabled, | |
| 1004 | - | .input-container { | |
| 1548 | + | .input-container input:disabled { | |
| 1005 | 1549 | opacity: 0.7; | |
| 1006 | 1550 | background-color: rgba(128, 128, 128, 0.1); | |
| 1007 | 1551 | cursor: not-allowed; | |
| @@ -1160,10 +1704,6 @@ | |||
| 1160 | 1704 | font-size: 0.875rem; | |
| 1161 | 1705 | } | |
| 1162 | 1706 | ||
| 1163 | - | .error-banner small { | |
| 1164 | - | color: #666; | |
| 1165 | - | } | |
| 1166 | - | ||
| 1167 | 1707 | .rate-limit-warning { | |
| 1168 | 1708 | background: #fff3e0; | |
| 1169 | 1709 | color: #e65100; | |
| @@ -1198,6 +1738,13 @@ | |||
| 1198 | 1738 | } | |
| 1199 | 1739 | } | |
| 1200 | 1740 | ||
| 1741 | + | .message.mention-highlight { | |
| 1742 | + | background: rgba(255, 237, 186, 0.45); | |
| 1743 | + | border-radius: 4px; | |
| 1744 | + | padding: 0.2rem 0.35rem; | |
| 1745 | + | box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2); | |
| 1746 | + | } | |
| 1747 | + | ||
| 1201 | 1748 | .cooldown-progress { | |
| 1202 | 1749 | position: absolute; | |
| 1203 | 1750 | bottom: 0; | |
| @@ -4,7 +4,8 @@ | |||||
| 4 | import { api } from '../../convex/_generated/api'; | 4 | import { api } from '../../convex/_generated/api'; | ||
| 5 | import type { Id } from '../../convex/_generated/dataModel'; | 5 | import type { Id } from '../../convex/_generated/dataModel'; | ||
| 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | ||
| 7 | - | import { onMount } from 'svelte'; | 7 | + | import { onMount, tick } from 'svelte'; |
| 8 | + | import { SvelteSet } from 'svelte/reactivity'; | |||
| 8 | import { browser } from '$app/environment'; | 9 | import { browser } from '$app/environment'; | ||
| 9 | import { draggable } from '$lib/actions/draggable'; | 10 | import { draggable } from '$lib/actions/draggable'; | ||
| 10 | import { resizable } from '$lib/actions/resizable'; | 11 | import { resizable } from '$lib/actions/resizable'; | ||
| @@ -16,11 +17,17 @@ | |||||
| 16 | import AimLogin from './aim-login.svelte'; | 17 | import AimLogin from './aim-login.svelte'; | ||
| 17 | import FormattedMessage from './formatted-message.svelte'; | 18 | import FormattedMessage from './formatted-message.svelte'; | ||
| 18 | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | 19 | import TextFormattingToolbar from './text-formatting-toolbar.svelte'; | ||
| 20 | + | import MentionPicker from './mention-picker.svelte'; | |||
| 19 | import { | 21 | import { | ||
| 20 | - | DEFAULT_TEXT_STYLE, | 22 | + | MAX_MENTION_SUGGESTIONS, |
| 21 | - | type TextStyle, | 23 | + | MENTION_PICKER, |
| 22 | - | generateInputCSSStyle | 24 | + | findMentionContext, |
| 23 | - | } from '../types/text-formatting'; | 25 | + | highlightMentionsInput, |
| 26 | + | containsMention, | |||
| 27 | + | calculatePickerHeight, | |||
| 28 | + | calculatePickerWidth | |||
| 29 | + | } from '../utils/mention'; | |||
| 30 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '../types/text-formatting'; | |||
| 24 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | 31 | import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format'; | ||
| 25 | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | 32 | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | ||
| 26 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | 33 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | ||
| @@ -146,12 +153,34 @@ | |||||
| 146 | 153 | ||||
| 147 | let currentMessage = $state(''); | 154 | let currentMessage = $state(''); | ||
| 148 | let inputScrollLeft = $state(0); | 155 | let inputScrollLeft = $state(0); | ||
| 156 | + | // Initialize text style with prop value (intentionally capturing initial value only) | |||
| 149 | let currentTextStyle = $state<TextStyle>({ | 157 | let currentTextStyle = $state<TextStyle>({ | ||
| 150 | ...DEFAULT_TEXT_STYLE, | 158 | ...DEFAULT_TEXT_STYLE, | ||
| 151 | ...initialTextStyle, | 159 | ...initialTextStyle, | ||
| 152 | color: initialTextStyle.color || '#000000' | 160 | color: initialTextStyle.color || '#000000' | ||
| 153 | }); | 161 | }); | ||
| 154 | 162 | ||||
| 163 | + | // ============ MENTION STATE ============ | |||
| 164 | + | ||||
| 165 | + | let messageInput = $state<HTMLInputElement | null>(null); | |||
| 166 | + | let mentionOpen = $state(false); | |||
| 167 | + | let mentionQuery = $state(''); | |||
| 168 | + | let mentionAnchorIndex = $state<number | null>(null); | |||
| 169 | + | let mentionSelectedIndex = $state(0); | |||
| 170 | + | let mentionObservedIds = $state<Set<string>>(new Set()); | |||
| 171 | + | let mentionMessageIds = $state<Set<string>>(new Set()); | |||
| 172 | + | let mentionUnreadCount = $state(0); | |||
| 173 | + | let mentionDismissKey = $state<string | null>(null); | |||
| 174 | + | let mentionObserver: IntersectionObserver | null = null; | |||
| 175 | + | ||||
| 176 | + | let baseTitle = $state(''); | |||
| 177 | + | const mentionListBoxId = `mentions-${Math.random().toString(36).slice(2, 8)}`; | |||
| 178 | + | let mentionActiveId = $state<string | null>(null); | |||
| 179 | + | let chatArea = $state<HTMLDivElement | null>(null); | |||
| 180 | + | let mentionListStyle = $state(''); | |||
| 181 | + | let mentionListInside = $state(true); | |||
| 182 | + | let mentionMeasureContext: CanvasRenderingContext2D | null = null; | |||
| 183 | + | ||||
| 155 | // ============ RATE LIMITING STATE ============ | 184 | // ============ RATE LIMITING STATE ============ | ||
| 156 | 185 | ||||
| 157 | let cooldownEndTime = $state<number | null>(null); | 186 | let cooldownEndTime = $state<number | null>(null); | ||
| @@ -198,19 +227,55 @@ | |||||
| 198 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | 227 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | ||
| 199 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | 228 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | ||
| 200 | 229 | ||||
| 230 | + | const mentionableUsers = $derived( | |||
| 231 | + | onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id) | |||
| 232 | + | ); | |||
| 233 | + | ||||
| 234 | + | // Filter users for mention autocomplete | |||
| 235 | + | // Empty query (just "@") shows all users to let user browse the list | |||
| 236 | + | const mentionSuggestions = $derived.by<SafeUser[]>(() => { | |||
| 237 | + | if (!mentionOpen) return []; | |||
| 238 | + | const query = mentionQuery.trim().toLowerCase(); | |||
| 239 | + | const candidates = query | |||
| 240 | + | ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query)) | |||
| 241 | + | : mentionableUsers; | |||
| 242 | + | return candidates.slice(0, MAX_MENTION_SUGGESTIONS); | |||
| 243 | + | }); | |||
| 244 | + | ||||
| 245 | + | // Format input message with mention highlighting (for overlay display) | |||
| 246 | + | const formattedInputMessage = $derived.by(() => { | |||
| 247 | + | if (!currentMessage) return ''; | |||
| 248 | + | // Escape HTML entities first | |||
| 249 | + | const escaped = currentMessage | |||
| 250 | + | .replace(/&/g, '&') | |||
| 251 | + | .replace(/</g, '<') | |||
| 252 | + | .replace(/>/g, '>'); | |||
| 253 | + | // Apply mention styling using shared utility | |||
| 254 | + | return highlightMentionsInput(escaped); | |||
| 255 | + | }); | |||
| 256 | + | ||||
| 257 | + | // mentionUnreadCount is managed in state updates | |||
| 258 | + | const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => { | |||
| 259 | + | const highlights: Record<string, boolean> = {}; | |||
| 260 | + | if (!currentUser) return highlights; | |||
| 261 | + | for (const message of visibleMessages) { | |||
| 262 | + | highlights[message.id] = shouldHighlightMention(message); | |||
| 263 | + | } | |||
| 264 | + | return highlights; | |||
| 265 | + | }); | |||
| 266 | + | ||||
| 201 | // ============ EFFECTS ============ | 267 | // ============ EFFECTS ============ | ||
| 202 | 268 | ||||
| 203 | // Auto-scroll to bottom when new messages arrive | 269 | // Auto-scroll to bottom when new messages arrive | ||
| 204 | $effect(() => { | 270 | $effect(() => { | ||
| 205 | if (messages.length > lastMessageCount && messages.length > 0) { | 271 | if (messages.length > lastMessageCount && messages.length > 0) { | ||
| 206 | lastMessageCount = messages.length; | 272 | lastMessageCount = messages.length; | ||
| 207 | - | if (browser) { | 273 | + | if (browser && chatArea) { |
| 208 | - | const chatArea = document.querySelector('.chat-area'); | 274 | + | tick().then(() => { |
| 209 | - | if (chatArea) { | 275 | + | if (chatArea) { |
| 210 | - | setTimeout(() => { | |||
| 211 | chatArea.scrollTop = chatArea.scrollHeight; | 276 | chatArea.scrollTop = chatArea.scrollHeight; | ||
| 212 | - | }, 0); | 277 | + | } |
| 213 | - | } | 278 | + | }); |
| 214 | } | 279 | } | ||
| 215 | } | 280 | } | ||
| 216 | }); | 281 | }); | ||
| @@ -222,6 +287,173 @@ | |||||
| 222 | } | 287 | } | ||
| 223 | }); | 288 | }); | ||
| 224 | 289 | ||||
| 290 | + | $effect(() => { | |||
| 291 | + | if (!browser) return; | |||
| 292 | + | if (!baseTitle) baseTitle = document.title; | |||
| 293 | + | const totalMentions = chatState.getMentionTotal(); | |||
| 294 | + | if (totalMentions > 0) { | |||
| 295 | + | document.title = `(${totalMentions}) ${baseTitle}`; | |||
| 296 | + | } else { | |||
| 297 | + | document.title = baseTitle; | |||
| 298 | + | } | |||
| 299 | + | }); | |||
| 300 | + | ||||
| 301 | + | $effect(() => { | |||
| 302 | + | chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount); | |||
| 303 | + | }); | |||
| 304 | + | ||||
| 305 | + | $effect(() => { | |||
| 306 | + | if (!browser || !currentUser) { | |||
| 307 | + | mentionObservedIds = new SvelteSet(); | |||
| 308 | + | mentionMessageIds = new SvelteSet(); | |||
| 309 | + | if (mentionUnreadCount !== 0) { | |||
| 310 | + | mentionUnreadCount = 0; | |||
| 311 | + | } | |||
| 312 | + | // mention observer reset | |||
| 313 | + | if (mentionObserver) { | |||
| 314 | + | mentionObserver.disconnect(); | |||
| 315 | + | mentionObserver = null; | |||
| 316 | + | } | |||
| 317 | + | return; | |||
| 318 | + | } | |||
| 319 | + | const mentionIds = new SvelteSet<string>(); | |||
| 320 | + | for (const message of visibleMessages) { | |||
| 321 | + | if (shouldHighlightMention(message)) { | |||
| 322 | + | mentionIds.add(message.id); | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | const nextMentionIds = mentionIds; | |||
| 326 | + | if (!setsEqual(nextMentionIds, mentionMessageIds)) { | |||
| 327 | + | mentionMessageIds = nextMentionIds; | |||
| 328 | + | } | |||
| 329 | + | const nextUnreadCount = Array.from(nextMentionIds).filter( | |||
| 330 | + | (messageId) => !mentionObservedIds.has(messageId) | |||
| 331 | + | ).length; | |||
| 332 | + | // mentionObservedIds is a local Set to avoid effect loops | |||
| 333 | + | if (mentionUnreadCount !== nextUnreadCount) { | |||
| 334 | + | mentionUnreadCount = nextUnreadCount; | |||
| 335 | + | } | |||
| 336 | + | }); | |||
| 337 | + | ||||
| 338 | + | $effect(() => { | |||
| 339 | + | const currentMentionIds = mentionMessageIds; | |||
| 340 | + | if (!browser || !chatArea || !currentUser) return; | |||
| 341 | + | if (mentionObserver) { | |||
| 342 | + | mentionObserver.disconnect(); | |||
| 343 | + | } | |||
| 344 | + | mentionObserver = new IntersectionObserver( | |||
| 345 | + | (entries) => { | |||
| 346 | + | const updatedObserved = new SvelteSet(mentionObservedIds); | |||
| 347 | + | let changed = false; | |||
| 348 | + | const currentObserved = mentionObservedIds; | |||
| 349 | + | const currentUnread = mentionUnreadCount; | |||
| 350 | + | for (const entry of entries) { | |||
| 351 | + | const target = entry.target as HTMLElement; | |||
| 352 | + | const messageId = target.dataset.messageId; | |||
| 353 | + | if (!messageId || !currentMentionIds.has(messageId)) continue; | |||
| 354 | + | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |||
| 355 | + | updatedObserved.add(messageId); | |||
| 356 | + | changed = true; | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |||
| 360 | + | mentionObservedIds = updatedObserved; | |||
| 361 | + | const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size); | |||
| 362 | + | if (currentUnread !== nextUnread) { | |||
| 363 | + | mentionUnreadCount = nextUnread; | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | }, | |||
| 367 | + | { root: chatArea, threshold: 0.6 } | |||
| 368 | + | ); | |||
| 369 | + | const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]')); | |||
| 370 | + | for (const node of nodes) { | |||
| 371 | + | const messageId = node.dataset.messageId; | |||
| 372 | + | if (messageId && currentMentionIds.has(messageId)) { | |||
| 373 | + | mentionObserver.observe(node); | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | // only observe mention messages | |||
| 377 | + | // mention observer reset | |||
| 378 | + | return () => { | |||
| 379 | + | mentionObserver?.disconnect(); | |||
| 380 | + | }; | |||
| 381 | + | }); | |||
| 382 | + | ||||
| 383 | + | $effect(() => { | |||
| 384 | + | if (!mentionOpen || mentionSuggestions.length === 0) { | |||
| 385 | + | if (mentionActiveId !== null) { | |||
| 386 | + | mentionActiveId = null; | |||
| 387 | + | } | |||
| 388 | + | return; | |||
| 389 | + | } | |||
| 390 | + | const currentActiveId = mentionActiveId; | |||
| 391 | + | let selectedIndex = mentionSelectedIndex; | |||
| 392 | + | if (selectedIndex >= mentionSuggestions.length) { | |||
| 393 | + | selectedIndex = 0; | |||
| 394 | + | if (mentionSelectedIndex !== 0) { | |||
| 395 | + | mentionSelectedIndex = 0; | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | const active = mentionSuggestions[selectedIndex]; | |||
| 399 | + | const nextActiveId = active ? getMentionListItemId(active.id) : null; | |||
| 400 | + | if (currentActiveId !== nextActiveId) { | |||
| 401 | + | mentionActiveId = nextActiveId; | |||
| 402 | + | } | |||
| 403 | + | }); | |||
| 404 | + | ||||
| 405 | + | $effect(() => { | |||
| 406 | + | if (!mentionOpen || !messageInput) { | |||
| 407 | + | if (mentionListStyle) mentionListStyle = ''; | |||
| 408 | + | return; | |||
| 409 | + | } | |||
| 410 | + | const currentListStyle = mentionListStyle; | |||
| 411 | + | const currentInside = mentionListInside; | |||
| 412 | + | const inputRect = messageInput.getBoundingClientRect(); | |||
| 413 | + | const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect(); | |||
| 414 | + | // Use shared utility for height calculation | |||
| 415 | + | const listHeight = calculatePickerHeight(mentionSuggestions.length); | |||
| 416 | + | const spaceBelow = containerRect | |||
| 417 | + | ? containerRect.bottom - inputRect.bottom | |||
| 418 | + | : window.innerHeight - inputRect.bottom; | |||
| 419 | + | const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top; | |||
| 420 | + | const showAbove = spaceBelow < listHeight && spaceAbove > listHeight; | |||
| 421 | + | const maxNameWidth = mentionSuggestions.reduce((max, user) => { | |||
| 422 | + | const width = measureMentionText(user.nickname, true, 13.6); | |||
| 423 | + | return Math.max(max, width); | |||
| 424 | + | }, 0); | |||
| 425 | + | // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing) | |||
| 426 | + | const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1; | |||
| 427 | + | // Use shared utility for width calculation | |||
| 428 | + | const listWidth = calculatePickerWidth(maxNameWidth, statusWidth); | |||
| 429 | + | if (containerRect) { | |||
| 430 | + | if (!currentInside) mentionListInside = true; | |||
| 431 | + | const top = showAbove | |||
| 432 | + | ? inputRect.top - containerRect.top - listHeight - MENTION_PICKER.OFFSET_GAP | |||
| 433 | + | : inputRect.bottom - containerRect.top + MENTION_PICKER.OFFSET_GAP; | |||
| 434 | + | const left = inputRect.left - containerRect.left; | |||
| 435 | + | const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, left)}px; width: ${listWidth}px;`; | |||
| 436 | + | if (currentListStyle !== nextStyle) { | |||
| 437 | + | mentionListStyle = nextStyle; | |||
| 438 | + | } | |||
| 439 | + | } else { | |||
| 440 | + | if (currentInside) mentionListInside = false; | |||
| 441 | + | const top = showAbove | |||
| 442 | + | ? inputRect.top - listHeight - MENTION_PICKER.OFFSET_GAP | |||
| 443 | + | : inputRect.bottom + MENTION_PICKER.OFFSET_GAP; | |||
| 444 | + | const nextStyle = `top: ${Math.max(MENTION_PICKER.EDGE_MARGIN, top)}px; left: ${Math.max(MENTION_PICKER.EDGE_MARGIN, inputRect.left)}px; width: ${listWidth}px;`; | |||
| 445 | + | if (currentListStyle !== nextStyle) { | |||
| 446 | + | mentionListStyle = nextStyle; | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | if (mentionSuggestions.length === 0 && mentionActiveId !== null) { | |||
| 450 | + | mentionActiveId = null; | |||
| 451 | + | } | |||
| 452 | + | if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) { | |||
| 453 | + | mentionSelectedIndex = 0; | |||
| 454 | + | } | |||
| 455 | + | }); | |||
| 456 | + | ||||
| 225 | // ============ HELPERS ============ | 457 | // ============ HELPERS ============ | ||
| 226 | 458 | ||||
| 227 | function resolveUser(senderId: string): SafeUser { | 459 | function resolveUser(senderId: string): SafeUser { | ||
| @@ -253,6 +485,143 @@ | |||||
| 253 | 485 | ||||
| 254 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | 486 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | ||
| 255 | 487 | ||||
| 488 | + | function setsEqual(a: Set<string>, b: Set<string>) { | |||
| 489 | + | if (a.size !== b.size) return false; | |||
| 490 | + | for (const value of a) { | |||
| 491 | + | if (!b.has(value)) return false; | |||
| 492 | + | } | |||
| 493 | + | return true; | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) { | |||
| 497 | + | if (!mentionMeasureContext) { | |||
| 498 | + | const canvas = document.createElement('canvas'); | |||
| 499 | + | mentionMeasureContext = canvas.getContext('2d'); | |||
| 500 | + | } | |||
| 501 | + | if (!mentionMeasureContext) return 0; | |||
| 502 | + | mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`; | |||
| 503 | + | return mentionMeasureContext.measureText(text).width; | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | function updateMentionState(input: HTMLInputElement) { | |||
| 507 | + | if (!currentUser) { | |||
| 508 | + | mentionOpen = false; | |||
| 509 | + | return; | |||
| 510 | + | } | |||
| 511 | + | const cursorIndex = input.selectionStart ?? input.value.length; | |||
| 512 | + | const context = findMentionContext(input.value, cursorIndex); | |||
| 513 | + | if (!context) { | |||
| 514 | + | closeMentionPicker(); | |||
| 515 | + | return; | |||
| 516 | + | } | |||
| 517 | + | const mentionKey = `${context.atIndex}:${context.query}`; | |||
| 518 | + | if (mentionDismissKey === mentionKey) { | |||
| 519 | + | return; | |||
| 520 | + | } | |||
| 521 | + | const wasOpen = mentionOpen; | |||
| 522 | + | const previousQuery = mentionQuery; | |||
| 523 | + | const sameAnchor = mentionAnchorIndex === context.atIndex; | |||
| 524 | + | mentionOpen = true; | |||
| 525 | + | mentionQuery = context.query; | |||
| 526 | + | mentionAnchorIndex = context.atIndex; | |||
| 527 | + | mentionDismissKey = null; | |||
| 528 | + | if (!wasOpen || !sameAnchor || previousQuery !== context.query) { | |||
| 529 | + | mentionSelectedIndex = 0; | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | function registerMentionNode(node: HTMLElement) { | |||
| 534 | + | if (!node) return; | |||
| 535 | + | const messageId = node.dataset.messageId; | |||
| 536 | + | if (!messageId) return; | |||
| 537 | + | if (mentionObserver) { | |||
| 538 | + | mentionObserver.observe(node); | |||
| 539 | + | } | |||
| 540 | + | // Return destroy method to properly cleanup when node is removed | |||
| 541 | + | return { | |||
| 542 | + | destroy() { | |||
| 543 | + | if (mentionObserver) { | |||
| 544 | + | mentionObserver.unobserve(node); | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 547 | + | }; | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | function closeMentionPicker() { | |||
| 551 | + | mentionOpen = false; | |||
| 552 | + | mentionQuery = ''; | |||
| 553 | + | mentionAnchorIndex = null; | |||
| 554 | + | mentionSelectedIndex = 0; | |||
| 555 | + | mentionActiveId = null; | |||
| 556 | + | mentionDismissKey = null; | |||
| 557 | + | if (messageInput) { | |||
| 558 | + | messageInput.focus(); | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | function clampMentionIndex(nextIndex: number) { | |||
| 563 | + | if (mentionSuggestions.length === 0) return 0; | |||
| 564 | + | const maxIndex = mentionSuggestions.length - 1; | |||
| 565 | + | if (nextIndex < 0) return maxIndex; | |||
| 566 | + | if (nextIndex > maxIndex) return 0; | |||
| 567 | + | return nextIndex; | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | function applyMentionSelection(user: SafeUser) { | |||
| 571 | + | if (!messageInput || mentionAnchorIndex === null) return; | |||
| 572 | + | const selectionStart = messageInput.selectionStart ?? currentMessage.length; | |||
| 573 | + | const before = currentMessage.slice(0, mentionAnchorIndex); | |||
| 574 | + | const after = currentMessage.slice(selectionStart); | |||
| 575 | + | const mentionText = `@${user.nickname} `; | |||
| 576 | + | currentMessage = `${before}${mentionText}${after}`; | |||
| 577 | + | void tick().then(() => { | |||
| 578 | + | if (!messageInput) return; | |||
| 579 | + | const cursor = before.length + mentionText.length; | |||
| 580 | + | messageInput.focus(); | |||
| 581 | + | messageInput.setSelectionRange(cursor, cursor); | |||
| 582 | + | }); | |||
| 583 | + | closeMentionPicker(); | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | function handleMentionKeydown(event: KeyboardEvent) { | |||
| 587 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |||
| 588 | + | if (event.key === 'ArrowDown') { | |||
| 589 | + | event.preventDefault(); | |||
| 590 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1); | |||
| 591 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 592 | + | return; | |||
| 593 | + | } | |||
| 594 | + | if (event.key === 'ArrowUp') { | |||
| 595 | + | event.preventDefault(); | |||
| 596 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1); | |||
| 597 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 598 | + | return; | |||
| 599 | + | } | |||
| 600 | + | if (event.key === 'Enter' || event.key === 'Tab') { | |||
| 601 | + | event.preventDefault(); | |||
| 602 | + | const choice = mentionSuggestions[mentionSelectedIndex]; | |||
| 603 | + | if (choice) applyMentionSelection(choice); | |||
| 604 | + | return; | |||
| 605 | + | } | |||
| 606 | + | if (event.key === 'Escape') { | |||
| 607 | + | event.preventDefault(); | |||
| 608 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |||
| 609 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |||
| 610 | + | } | |||
| 611 | + | closeMentionPicker(); | |||
| 612 | + | } | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | function getMentionListItemId(userId: string) { | |||
| 616 | + | return `${mentionListBoxId}-${userId}`; | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | function scrollMentionOptionIntoView() { | |||
| 620 | + | if (!mentionActiveId) return; | |||
| 621 | + | const option = document.getElementById(mentionActiveId); | |||
| 622 | + | option?.scrollIntoView({ block: 'nearest' }); | |||
| 623 | + | } | |||
| 624 | + | ||||
| 256 | function updateCooldownProgress() { | 625 | function updateCooldownProgress() { | ||
| 257 | if (!cooldownEndTime) return; | 626 | if (!cooldownEndTime) return; | ||
| 258 | const now = Date.now(); | 627 | const now = Date.now(); | ||
| @@ -282,6 +651,12 @@ | |||||
| 282 | showChatRoom = false; | 651 | showChatRoom = false; | ||
| 283 | } | 652 | } | ||
| 284 | 653 | ||||
| 654 | + | function shouldHighlightMention(message: EnrichedMessage) { | |||
| 655 | + | if (!currentUser) return false; | |||
| 656 | + | if (message.senderId === currentUser.id) return false; | |||
| 657 | + | return containsMention(message.content, currentUser.nickname); | |||
| 658 | + | } | |||
| 659 | + | ||||
| 285 | function handleInputChange(e: Event) { | 660 | function handleInputChange(e: Event) { | ||
| 286 | const input = e.target as HTMLInputElement; | 661 | const input = e.target as HTMLInputElement; | ||
| 287 | if (input.value.length > MAX_MESSAGE_LENGTH) { | 662 | if (input.value.length > MAX_MESSAGE_LENGTH) { | ||
| @@ -289,12 +664,51 @@ | |||||
| 289 | currentMessage = input.value; | 664 | currentMessage = input.value; | ||
| 290 | } | 665 | } | ||
| 291 | inputScrollLeft = input.scrollLeft; | 666 | inputScrollLeft = input.scrollLeft; | ||
| 667 | + | updateMentionState(input); | |||
| 668 | + | if (!mentionOpen && mentionQuery) { | |||
| 669 | + | mentionQuery = ''; | |||
| 670 | + | } | |||
| 292 | } | 671 | } | ||
| 293 | 672 | ||||
| 294 | function handleInputScroll(e: Event) { | 673 | function handleInputScroll(e: Event) { | ||
| 295 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | 674 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | ||
| 296 | } | 675 | } | ||
| 297 | 676 | ||||
| 677 | + | function handleInputFocus() { | |||
| 678 | + | if (!messageInput) return; | |||
| 679 | + | updateMentionState(messageInput); | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | function handleInputClick() { | |||
| 683 | + | if (!messageInput) return; | |||
| 684 | + | updateMentionState(messageInput); | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | function handleInputKeydown(event: KeyboardEvent) { | |||
| 688 | + | if (!messageInput) return; | |||
| 689 | + | if (event.key === 'Escape') { | |||
| 690 | + | event.preventDefault(); | |||
| 691 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |||
| 692 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |||
| 693 | + | } | |||
| 694 | + | closeMentionPicker(); | |||
| 695 | + | return; | |||
| 696 | + | } | |||
| 697 | + | if (mentionOpen) { | |||
| 698 | + | handleMentionKeydown(event); | |||
| 699 | + | return; | |||
| 700 | + | } | |||
| 701 | + | if (event.key === 'Enter') { | |||
| 702 | + | event.preventDefault(); | |||
| 703 | + | void handleSubmit(); | |||
| 704 | + | } | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | function handleInputKeyup() { | |||
| 708 | + | if (!messageInput) return; | |||
| 709 | + | updateMentionState(messageInput); | |||
| 710 | + | } | |||
| 711 | + | ||||
| 298 | async function handleSubmit() { | 712 | async function handleSubmit() { | ||
| 299 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | 713 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | ||
| 300 | 714 | ||||
| @@ -520,6 +934,8 @@ | |||||
| 520 | return () => { | 934 | return () => { | ||
| 521 | window.removeEventListener('resize', handleResize); | 935 | window.removeEventListener('resize', handleResize); | ||
| 522 | if (cooldownInterval) clearInterval(cooldownInterval); | 936 | if (cooldownInterval) clearInterval(cooldownInterval); | ||
| 937 | + | // Clean up canvas context used for text measurement | |||
| 938 | + | mentionMeasureContext = null; | |||
| 523 | }; | 939 | }; | ||
| 524 | }); | 940 | }); | ||
| 525 | 941 | ||||
| @@ -537,6 +953,14 @@ | |||||
| 537 | isCentered | 953 | isCentered | ||
| 538 | }); | 954 | }); | ||
| 539 | }); | 955 | }); | ||
| 956 | + | ||||
| 957 | + | // Scroll selected mention option into view when selection changes | |||
| 958 | + | $effect(() => { | |||
| 959 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |||
| 960 | + | // Track mentionSelectedIndex to trigger scroll on keyboard navigation | |||
| 961 | + | void mentionSelectedIndex; | |||
| 962 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 963 | + | }); | |||
| 540 | </script> | 964 | </script> | ||
| 541 | 965 | ||||
| 542 | {#if showChatRoom} | 966 | {#if showChatRoom} | ||
| @@ -567,7 +991,8 @@ | |||||
| 567 | class="title-bar-text" | 991 | class="title-bar-text" | ||
| 568 | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | 992 | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | ||
| 569 | > | 993 | > | ||
| 570 | - | Pdr Aim {#if currentUser} | 994 | + | Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount}) |
| 995 | + | {/if}{#if currentUser} | |||
| 571 | - {currentUser.nickname}{:else} | 996 | - {currentUser.nickname}{:else} | ||
| 572 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | 997 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | ||
| 573 | {#if connectionError && !isMinimized} | 998 | {#if connectionError && !isMinimized} | ||
| @@ -640,6 +1065,7 @@ | |||||
| 640 | class="sunken-panel chat-area" | 1065 | class="sunken-panel chat-area" | ||
| 641 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 1066 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | ||
| 642 | onscroll={(e) => handleScroll(e)} | 1067 | onscroll={(e) => handleScroll(e)} | ||
| 1068 | + | bind:this={chatArea} | |||
| 643 | > | 1069 | > | ||
| 644 | {#if isInitialLoading} | 1070 | {#if isInitialLoading} | ||
| 645 | <div class="initial-loading"> | 1071 | <div class="initial-loading"> | ||
| @@ -659,7 +1085,18 @@ | |||||
| 659 | </div> | 1085 | </div> | ||
| 660 | {/if} | 1086 | {/if} | ||
| 661 | {#each visibleMessages as message (message.id)} | 1087 | {#each visibleMessages as message (message.id)} | ||
| 662 | - | <div class="message {message.type} text"> | 1088 | + | <div |
| 1089 | + | class="message {message.type} text" | |||
| 1090 | + | class:mention-highlight={hasMentionHighlight[message.id]} | |||
| 1091 | + | data-message-id={message.id} | |||
| 1092 | + | data-mention={hasMentionHighlight[message.id] | |||
| 1093 | + | ? `Mention de @${currentUser?.nickname ?? ''}` | |||
| 1094 | + | : undefined} | |||
| 1095 | + | aria-label={hasMentionHighlight[message.id] | |||
| 1096 | + | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |||
| 1097 | + | : undefined} | |||
| 1098 | + | use:registerMentionNode | |||
| 1099 | + | > | |||
| 663 | {#if message.type === 'emote'} | 1100 | {#if message.type === 'emote'} | ||
| 664 | <span class="emote-text"> | 1101 | <span class="emote-text"> | ||
| 665 | <Tooltip | 1102 | <Tooltip | ||
| @@ -737,8 +1174,11 @@ | |||||
| 737 | -webkit-text-fill-color: transparent; | 1174 | -webkit-text-fill-color: transparent; | ||
| 738 | background-clip: text; | 1175 | background-clip: text; | ||
| 739 | " | 1176 | " | ||
| 740 | - | aria-hidden="true">{currentMessage}</span | 1177 | + | aria-hidden="true" |
| 741 | > | 1178 | > | ||
| 1179 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | |||
| 1180 | + | {@html formattedInputMessage} | |||
| 1181 | + | </span> | |||
| 742 | <input | 1182 | <input | ||
| 743 | type="text" | 1183 | type="text" | ||
| 744 | bind:value={currentMessage} | 1184 | bind:value={currentMessage} | ||
| @@ -753,28 +1193,86 @@ | |||||
| 753 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | 1193 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | ||
| 754 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1194 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | ||
| 755 | : ''}" | 1195 | : ''}" | ||
| 756 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 1196 | + | onkeydown={handleInputKeydown} |
| 1197 | + | onkeyup={handleInputKeyup} | |||
| 757 | oninput={handleInputChange} | 1198 | oninput={handleInputChange} | ||
| 758 | onscroll={handleInputScroll} | 1199 | onscroll={handleInputScroll} | ||
| 1200 | + | onfocus={handleInputFocus} | |||
| 1201 | + | onclick={handleInputClick} | |||
| 1202 | + | bind:this={messageInput} | |||
| 759 | placeholder={cooldownEndTime | 1203 | placeholder={cooldownEndTime | ||
| 760 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1204 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | ||
| 761 | : 'Écrivez un message...'} | 1205 | : 'Écrivez un message...'} | ||
| 762 | disabled={!currentUser || Boolean(cooldownEndTime)} | 1206 | disabled={!currentUser || Boolean(cooldownEndTime)} | ||
| 1207 | + | role="combobox" | |||
| 1208 | + | aria-autocomplete="list" | |||
| 1209 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |||
| 1210 | + | aria-expanded={mentionOpen} | |||
| 763 | /> | 1211 | /> | ||
| 764 | </div> | 1212 | </div> | ||
| 765 | {:else} | 1213 | {:else} | ||
| 766 | - | <input | 1214 | + | <div |
| 767 | - | type="text" | 1215 | + | class="mention-input-wrapper" |
| 768 | - | bind:value={currentMessage} | 1216 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" |
| 769 | - | maxlength={MAX_MESSAGE_LENGTH} | 1217 | + | > |
| 770 | - | class="styled-input retro-font-{currentTextStyle.fontFamily}" | 1218 | + | <!-- Text overlay with mention highlighting --> |
| 771 | - | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | 1219 | + | <span |
| 772 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 1220 | + | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" |
| 773 | - | placeholder={cooldownEndTime | 1221 | + | style=" |
| 774 | - | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1222 | + | position: absolute; |
| 775 | - | : 'Écrivez un message...'} | 1223 | + | left: 3px; |
| 776 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | 1224 | + | top: 50%; |
| 777 | - | /> | 1225 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); |
| 1226 | + | pointer-events: none; | |||
| 1227 | + | white-space: nowrap; | |||
| 1228 | + | font-size: {currentTextStyle.fontSize}px; | |||
| 1229 | + | color: {currentTextStyle.color || '#000000'}; | |||
| 1230 | + | {currentTextStyle.bold | |||
| 1231 | + | ? currentTextStyle.fontFamily === 'tahoma' | |||
| 1232 | + | ? 'font-weight: 200;' | |||
| 1233 | + | : 'font-weight: 700;' | |||
| 1234 | + | : ''} | |||
| 1235 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |||
| 1236 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 1237 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 1238 | + | : ''} | |||
| 1239 | + | " | |||
| 1240 | + | aria-hidden="true" | |||
| 1241 | + | > | |||
| 1242 | + | <!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedInputMessage is sanitized by highlightMentionsInput --> | |||
| 1243 | + | {@html formattedInputMessage} | |||
| 1244 | + | </span> | |||
| 1245 | + | <input | |||
| 1246 | + | type="text" | |||
| 1247 | + | bind:value={currentMessage} | |||
| 1248 | + | maxlength={MAX_MESSAGE_LENGTH} | |||
| 1249 | + | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" | |||
| 1250 | + | style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold | |||
| 1251 | + | ? currentTextStyle.fontFamily === 'tahoma' | |||
| 1252 | + | ? 'font-weight: 200;' | |||
| 1253 | + | : 'font-weight: 700;' | |||
| 1254 | + | : ''} {currentTextStyle.italic | |||
| 1255 | + | ? 'font-style: italic;' | |||
| 1256 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 1257 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 1258 | + | : ''} caret-color: {currentTextStyle.color || 'black'};" | |||
| 1259 | + | onkeydown={handleInputKeydown} | |||
| 1260 | + | onkeyup={handleInputKeyup} | |||
| 1261 | + | oninput={handleInputChange} | |||
| 1262 | + | onscroll={handleInputScroll} | |||
| 1263 | + | onfocus={handleInputFocus} | |||
| 1264 | + | onclick={handleInputClick} | |||
| 1265 | + | bind:this={messageInput} | |||
| 1266 | + | placeholder={cooldownEndTime | |||
| 1267 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |||
| 1268 | + | : 'Écrivez un message...'} | |||
| 1269 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |||
| 1270 | + | role="combobox" | |||
| 1271 | + | aria-autocomplete="list" | |||
| 1272 | + | aria-controls={mentionOpen ? mentionListBoxId : undefined} | |||
| 1273 | + | aria-expanded={mentionOpen} | |||
| 1274 | + | /> | |||
| 1275 | + | </div> | |||
| 778 | {/if} | 1276 | {/if} | ||
| 779 | <LoadingButton | 1277 | <LoadingButton | ||
| 780 | onclick={handleSubmit} | 1278 | onclick={handleSubmit} | ||
| @@ -788,6 +1286,18 @@ | |||||
| 788 | </span> | 1286 | </span> | ||
| 789 | {/if} | 1287 | {/if} | ||
| 790 | </div> | 1288 | </div> | ||
| 1289 | + | {#if mentionOpen} | |||
| 1290 | + | <MentionPicker | |||
| 1291 | + | suggestions={mentionSuggestions} | |||
| 1292 | + | selectedIndex={mentionSelectedIndex} | |||
| 1293 | + | activeId={mentionActiveId} | |||
| 1294 | + | listBoxId={mentionListBoxId} | |||
| 1295 | + | listStyle={mentionListStyle} | |||
| 1296 | + | isPortal={!mentionListInside} | |||
| 1297 | + | onSelect={applyMentionSelection} | |||
| 1298 | + | /> | |||
| 1299 | + | {/if} | |||
| 1300 | + | ||||
| 791 | {#if cooldownEndTime} | 1301 | {#if cooldownEndTime} | ||
| 792 | <div | 1302 | <div | ||
| 793 | class="cooldown-progress" | 1303 | class="cooldown-progress" | ||
| @@ -936,6 +1446,41 @@ | |||||
| 936 | color: #666; | 1446 | color: #666; | ||
| 937 | } | 1447 | } | ||
| 938 | 1448 | ||||
| 1449 | + | .message-content :global(.mention-token) { | |||
| 1450 | + | color: #1e2a72; | |||
| 1451 | + | font-weight: bold; | |||
| 1452 | + | } | |||
| 1453 | + | ||||
| 1454 | + | /* Input mention highlighting */ | |||
| 1455 | + | .mention-input-wrapper, | |||
| 1456 | + | .gradient-input-wrapper { | |||
| 1457 | + | flex: 1; | |||
| 1458 | + | position: relative; | |||
| 1459 | + | background: white; | |||
| 1460 | + | overflow: hidden; | |||
| 1461 | + | } | |||
| 1462 | + | ||||
| 1463 | + | .mention-text-overlay, | |||
| 1464 | + | .gradient-text-overlay { | |||
| 1465 | + | position: absolute; | |||
| 1466 | + | left: 3px; | |||
| 1467 | + | top: 50%; | |||
| 1468 | + | pointer-events: none; | |||
| 1469 | + | white-space: nowrap; | |||
| 1470 | + | } | |||
| 1471 | + | ||||
| 1472 | + | .mention-text-overlay :global(.input-mention), | |||
| 1473 | + | .gradient-text-overlay :global(.input-mention) { | |||
| 1474 | + | color: #1e2a72; | |||
| 1475 | + | -webkit-text-fill-color: #1e2a72; | |||
| 1476 | + | } | |||
| 1477 | + | ||||
| 1478 | + | /* Force input text to be transparent when using overlay */ | |||
| 1479 | + | .mention-overlay-input { | |||
| 1480 | + | color: transparent !important; | |||
| 1481 | + | -webkit-text-fill-color: transparent !important; | |||
| 1482 | + | } | |||
| 1483 | + | ||||
| 939 | .section-header { | 1484 | .section-header { | ||
| 940 | margin: 0 0 0.25rem 0; | 1485 | margin: 0 0 0.25rem 0; | ||
| 941 | font-weight: bold; | 1486 | font-weight: bold; | ||
| @@ -1000,8 +1545,7 @@ | |||||
| 1000 | z-index: 2; | 1545 | z-index: 2; | ||
| 1001 | } | 1546 | } | ||
| 1002 | 1547 | ||||
| 1003 | - | .input-container input:disabled, | 1548 | + | .input-container input:disabled { |
| 1004 | - | .input-container { | |||
| 1005 | opacity: 0.7; | 1549 | opacity: 0.7; | ||
| 1006 | background-color: rgba(128, 128, 128, 0.1); | 1550 | background-color: rgba(128, 128, 128, 0.1); | ||
| 1007 | cursor: not-allowed; | 1551 | cursor: not-allowed; | ||
| @@ -1160,10 +1704,6 @@ | |||||
| 1160 | font-size: 0.875rem; | 1704 | font-size: 0.875rem; | ||
| 1161 | } | 1705 | } | ||
| 1162 | 1706 | ||||
| 1163 | - | .error-banner small { | |||
| 1164 | - | color: #666; | |||
| 1165 | - | } | |||
| 1166 | - | ||||
| 1167 | .rate-limit-warning { | 1707 | .rate-limit-warning { | ||
| 1168 | background: #fff3e0; | 1708 | background: #fff3e0; | ||
| 1169 | color: #e65100; | 1709 | color: #e65100; | ||
| @@ -1198,6 +1738,13 @@ | |||||
| 1198 | } | 1738 | } | ||
| 1199 | } | 1739 | } | ||
| 1200 | 1740 | ||||
| 1741 | + | .message.mention-highlight { | |||
| 1742 | + | background: rgba(255, 237, 186, 0.45); | |||
| 1743 | + | border-radius: 4px; | |||
| 1744 | + | padding: 0.2rem 0.35rem; | |||
| 1745 | + | box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2); | |||
| 1746 | + | } | |||
| 1747 | + | ||||
| 1201 | .cooldown-progress { | 1748 | .cooldown-progress { | ||
| 1202 | position: absolute; | 1749 | position: absolute; | ||
| 1203 | bottom: 0; | 1750 | bottom: 0; | ||
M src/lib/components/formatted-message.svelte +9 -1
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | import type { TextStyle } from '../types/text-formatting'; | |
| 4 | 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | |
| 5 | 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | |
| 6 | + | import { highlightMentionsPlain } from '../utils/mention'; | |
| 6 | 7 | ||
| 7 | 8 | // Props | |
| 8 | 9 | let { message, allowFormatting = true } = $props<{ | |
| @@ -18,7 +19,8 @@ | |||
| 18 | 19 | // Process message formatting | |
| 19 | 20 | async function processMessage() { | |
| 20 | 21 | if (!allowFormatting || !message.hasFormatting) { | |
| 21 | - | formattedContent = escapeHtml(message.content); | |
| 22 | + | // Still apply mention highlighting to plain text messages | |
| 23 | + | formattedContent = highlightMentionsPlain(escapeHtml(message.content)); | |
| 22 | 24 | return; | |
| 23 | 25 | } | |
| 24 | 26 | ||
| @@ -234,6 +236,12 @@ | |||
| 234 | 236 | color: inherit; | |
| 235 | 237 | } | |
| 236 | 238 | ||
| 239 | + | /* Mention token styling */ | |
| 240 | + | :global(.mention-token) { | |
| 241 | + | color: #1e2a72; | |
| 242 | + | font-weight: bold; | |
| 243 | + | } | |
| 244 | + | ||
| 237 | 245 | /* Ensure accessibility for screen readers */ | |
| 238 | 246 | :global(.formatted-message[aria-label]) { | |
| 239 | 247 | position: relative; | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | import type { TextStyle } from '../types/text-formatting'; | 3 | import type { TextStyle } from '../types/text-formatting'; | ||
| 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | 4 | import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter'; | ||
| 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | 5 | import { DEFAULT_TEXT_STYLE } from '../types/text-formatting'; | ||
| 6 | + | import { highlightMentionsPlain } from '../utils/mention'; | |||
| 6 | 7 | ||||
| 7 | // Props | 8 | // Props | ||
| 8 | let { message, allowFormatting = true } = $props<{ | 9 | let { message, allowFormatting = true } = $props<{ | ||
| @@ -18,7 +19,8 @@ | |||||
| 18 | // Process message formatting | 19 | // Process message formatting | ||
| 19 | async function processMessage() { | 20 | async function processMessage() { | ||
| 20 | if (!allowFormatting || !message.hasFormatting) { | 21 | if (!allowFormatting || !message.hasFormatting) { | ||
| 21 | - | formattedContent = escapeHtml(message.content); | 22 | + | // Still apply mention highlighting to plain text messages |
| 23 | + | formattedContent = highlightMentionsPlain(escapeHtml(message.content)); | |||
| 22 | return; | 24 | return; | ||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| @@ -234,6 +236,12 @@ | |||||
| 234 | color: inherit; | 236 | color: inherit; | ||
| 235 | } | 237 | } | ||
| 236 | 238 | ||||
| 239 | + | /* Mention token styling */ | |||
| 240 | + | :global(.mention-token) { | |||
| 241 | + | color: #1e2a72; | |||
| 242 | + | font-weight: bold; | |||
| 243 | + | } | |||
| 244 | + | ||||
| 237 | /* Ensure accessibility for screen readers */ | 245 | /* Ensure accessibility for screen readers */ | ||
| 238 | :global(.formatted-message[aria-label]) { | 246 | :global(.formatted-message[aria-label]) { | ||
| 239 | position: relative; | 247 | position: relative; | ||
A src/lib/components/mention-picker.svelte +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import type { SafeUser } from '../types/chat'; | |
| 3 | + | ||
| 4 | + | // Props | |
| 5 | + | let { | |
| 6 | + | suggestions, | |
| 7 | + | selectedIndex, | |
| 8 | + | activeId, | |
| 9 | + | listBoxId, | |
| 10 | + | listStyle, | |
| 11 | + | isPortal = false, | |
| 12 | + | emptyMessage = 'Aucun utilisateur', | |
| 13 | + | onSelect, | |
| 14 | + | onMouseDown | |
| 15 | + | } = $props<{ | |
| 16 | + | suggestions: SafeUser[]; | |
| 17 | + | selectedIndex: number; | |
| 18 | + | activeId: string | null; | |
| 19 | + | listBoxId: string; | |
| 20 | + | listStyle: string; | |
| 21 | + | isPortal?: boolean; | |
| 22 | + | emptyMessage?: string; | |
| 23 | + | onSelect: (user: SafeUser) => void; | |
| 24 | + | onMouseDown?: (event: MouseEvent) => void; | |
| 25 | + | }>(); | |
| 26 | + | ||
| 27 | + | // Generate option ID from user ID | |
| 28 | + | function getOptionId(userId: string): string { | |
| 29 | + | return `${listBoxId}-${userId}`; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | function handleOptionMouseDown(event: MouseEvent, user: SafeUser) { | |
| 33 | + | event.preventDefault(); | |
| 34 | + | onSelect(user); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | function handleContainerMouseDown(event: MouseEvent) { | |
| 38 | + | event.preventDefault(); | |
| 39 | + | onMouseDown?.(event); | |
| 40 | + | } | |
| 41 | + | </script> | |
| 42 | + | ||
| 43 | + | <div | |
| 44 | + | id={listBoxId} | |
| 45 | + | class="mention-picker" | |
| 46 | + | class:mention-picker-portal={isPortal} | |
| 47 | + | role="listbox" | |
| 48 | + | aria-label="Suggestions de mentions" | |
| 49 | + | aria-activedescendant={activeId} | |
| 50 | + | tabindex="-1" | |
| 51 | + | style={`z-index: ${isPortal ? 3000 : 10}; ${listStyle}`} | |
| 52 | + | onmousedown={handleContainerMouseDown} | |
| 53 | + | > | |
| 54 | + | {#if suggestions.length > 0} | |
| 55 | + | {#each suggestions as user, index (user.id)} | |
| 56 | + | <div | |
| 57 | + | id={getOptionId(user.id)} | |
| 58 | + | role="option" | |
| 59 | + | class="mention-option" | |
| 60 | + | class:selected={index === selectedIndex} | |
| 61 | + | aria-selected={index === selectedIndex} | |
| 62 | + | tabindex="-1" | |
| 63 | + | onmousedown={(event) => handleOptionMouseDown(event, user)} | |
| 64 | + | > | |
| 65 | + | <span class="mention-name">{user.nickname}</span> | |
| 66 | + | <span class="mention-status {user.status}">{user.status}</span> | |
| 67 | + | </div> | |
| 68 | + | {/each} | |
| 69 | + | {:else} | |
| 70 | + | <div | |
| 71 | + | class="mention-empty" | |
| 72 | + | role="option" | |
| 73 | + | aria-disabled="true" | |
| 74 | + | aria-selected="false" | |
| 75 | + | tabindex="-1" | |
| 76 | + | > | |
| 77 | + | {emptyMessage} | |
| 78 | + | </div> | |
| 79 | + | {/if} | |
| 80 | + | </div> | |
| 81 | + | ||
| 82 | + | <style> | |
| 83 | + | .mention-picker { | |
| 84 | + | position: absolute; | |
| 85 | + | padding: 0.35rem; | |
| 86 | + | max-height: 220px; | |
| 87 | + | overflow-y: auto; | |
| 88 | + | background: #fffef5; | |
| 89 | + | border: 2px inset #dfdfdf; | |
| 90 | + | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15); | |
| 91 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |
| 92 | + | font-size: 0.85rem; | |
| 93 | + | min-width: 220px; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | .mention-picker-portal { | |
| 97 | + | position: fixed; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | .mention-option { | |
| 101 | + | display: flex; | |
| 102 | + | align-items: center; | |
| 103 | + | justify-content: space-between; | |
| 104 | + | gap: 0.5rem; | |
| 105 | + | padding: 0.25rem 0.4rem; | |
| 106 | + | border: 1px solid transparent; | |
| 107 | + | cursor: pointer; | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | .mention-option.selected { | |
| 111 | + | background: #dbe8ff; | |
| 112 | + | border-color: #2d31a6; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | .mention-name { | |
| 116 | + | font-weight: bold; | |
| 117 | + | color: #1e2a72; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | .mention-status { | |
| 121 | + | font-size: 0.7rem; | |
| 122 | + | text-transform: uppercase; | |
| 123 | + | letter-spacing: 0.05em; | |
| 124 | + | color: #5b5b5b; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | .mention-status.online { | |
| 128 | + | color: #1f7a1f; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | .mention-status.away, | |
| 132 | + | .mention-status.busy { | |
| 133 | + | color: #b06a00; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | .mention-status.offline { | |
| 137 | + | color: #8a8a8a; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | .mention-empty { | |
| 141 | + | padding: 0.35rem 0.5rem; | |
| 142 | + | color: #777; | |
| 143 | + | font-style: italic; | |
| 144 | + | } | |
| 145 | + | </style> | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import type { SafeUser } from '../types/chat'; | |||
| 3 | + | ||||
| 4 | + | // Props | |||
| 5 | + | let { | |||
| 6 | + | suggestions, | |||
| 7 | + | selectedIndex, | |||
| 8 | + | activeId, | |||
| 9 | + | listBoxId, | |||
| 10 | + | listStyle, | |||
| 11 | + | isPortal = false, | |||
| 12 | + | emptyMessage = 'Aucun utilisateur', | |||
| 13 | + | onSelect, | |||
| 14 | + | onMouseDown | |||
| 15 | + | } = $props<{ | |||
| 16 | + | suggestions: SafeUser[]; | |||
| 17 | + | selectedIndex: number; | |||
| 18 | + | activeId: string | null; | |||
| 19 | + | listBoxId: string; | |||
| 20 | + | listStyle: string; | |||
| 21 | + | isPortal?: boolean; | |||
| 22 | + | emptyMessage?: string; | |||
| 23 | + | onSelect: (user: SafeUser) => void; | |||
| 24 | + | onMouseDown?: (event: MouseEvent) => void; | |||
| 25 | + | }>(); | |||
| 26 | + | ||||
| 27 | + | // Generate option ID from user ID | |||
| 28 | + | function getOptionId(userId: string): string { | |||
| 29 | + | return `${listBoxId}-${userId}`; | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | function handleOptionMouseDown(event: MouseEvent, user: SafeUser) { | |||
| 33 | + | event.preventDefault(); | |||
| 34 | + | onSelect(user); | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | function handleContainerMouseDown(event: MouseEvent) { | |||
| 38 | + | event.preventDefault(); | |||
| 39 | + | onMouseDown?.(event); | |||
| 40 | + | } | |||
| 41 | + | </script> | |||
| 42 | + | ||||
| 43 | + | <div | |||
| 44 | + | id={listBoxId} | |||
| 45 | + | class="mention-picker" | |||
| 46 | + | class:mention-picker-portal={isPortal} | |||
| 47 | + | role="listbox" | |||
| 48 | + | aria-label="Suggestions de mentions" | |||
| 49 | + | aria-activedescendant={activeId} | |||
| 50 | + | tabindex="-1" | |||
| 51 | + | style={`z-index: ${isPortal ? 3000 : 10}; ${listStyle}`} | |||
| 52 | + | onmousedown={handleContainerMouseDown} | |||
| 53 | + | > | |||
| 54 | + | {#if suggestions.length > 0} | |||
| 55 | + | {#each suggestions as user, index (user.id)} | |||
| 56 | + | <div | |||
| 57 | + | id={getOptionId(user.id)} | |||
| 58 | + | role="option" | |||
| 59 | + | class="mention-option" | |||
| 60 | + | class:selected={index === selectedIndex} | |||
| 61 | + | aria-selected={index === selectedIndex} | |||
| 62 | + | tabindex="-1" | |||
| 63 | + | onmousedown={(event) => handleOptionMouseDown(event, user)} | |||
| 64 | + | > | |||
| 65 | + | <span class="mention-name">{user.nickname}</span> | |||
| 66 | + | <span class="mention-status {user.status}">{user.status}</span> | |||
| 67 | + | </div> | |||
| 68 | + | {/each} | |||
| 69 | + | {:else} | |||
| 70 | + | <div | |||
| 71 | + | class="mention-empty" | |||
| 72 | + | role="option" | |||
| 73 | + | aria-disabled="true" | |||
| 74 | + | aria-selected="false" | |||
| 75 | + | tabindex="-1" | |||
| 76 | + | > | |||
| 77 | + | {emptyMessage} | |||
| 78 | + | </div> | |||
| 79 | + | {/if} | |||
| 80 | + | </div> | |||
| 81 | + | ||||
| 82 | + | <style> | |||
| 83 | + | .mention-picker { | |||
| 84 | + | position: absolute; | |||
| 85 | + | padding: 0.35rem; | |||
| 86 | + | max-height: 220px; | |||
| 87 | + | overflow-y: auto; | |||
| 88 | + | background: #fffef5; | |||
| 89 | + | border: 2px inset #dfdfdf; | |||
| 90 | + | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15); | |||
| 91 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |||
| 92 | + | font-size: 0.85rem; | |||
| 93 | + | min-width: 220px; | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | .mention-picker-portal { | |||
| 97 | + | position: fixed; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | .mention-option { | |||
| 101 | + | display: flex; | |||
| 102 | + | align-items: center; | |||
| 103 | + | justify-content: space-between; | |||
| 104 | + | gap: 0.5rem; | |||
| 105 | + | padding: 0.25rem 0.4rem; | |||
| 106 | + | border: 1px solid transparent; | |||
| 107 | + | cursor: pointer; | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | .mention-option.selected { | |||
| 111 | + | background: #dbe8ff; | |||
| 112 | + | border-color: #2d31a6; | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | .mention-name { | |||
| 116 | + | font-weight: bold; | |||
| 117 | + | color: #1e2a72; | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | .mention-status { | |||
| 121 | + | font-size: 0.7rem; | |||
| 122 | + | text-transform: uppercase; | |||
| 123 | + | letter-spacing: 0.05em; | |||
| 124 | + | color: #5b5b5b; | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | .mention-status.online { | |||
| 128 | + | color: #1f7a1f; | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | .mention-status.away, | |||
| 132 | + | .mention-status.busy { | |||
| 133 | + | color: #b06a00; | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | .mention-status.offline { | |||
| 137 | + | color: #8a8a8a; | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | .mention-empty { | |||
| 141 | + | padding: 0.35rem 0.5rem; | |||
| 142 | + | color: #777; | |||
| 143 | + | font-style: italic; | |||
| 144 | + | } | |||
| 145 | + | </style> | |||
M src/lib/components/text-formatting-toolbar.svelte +8 -3
| @@ -87,7 +87,7 @@ | |||
| 87 | 87 | title="Font Family" | |
| 88 | 88 | > | |
| 89 | 89 | {#each Object.entries(RETRO_FONTS) as [key, font] (key)} | |
| 90 | - | <option value={key} style="font-family: {font.stack};">{font.name}</option> | |
| 90 | + | <option value={key}>{font.shortName}</option> | |
| 91 | 91 | {/each} | |
| 92 | 92 | </select> | |
| 93 | 93 | {/if} | |
| @@ -182,17 +182,22 @@ | |||
| 182 | 182 | gap: 1px; | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | /* Fix font dropdown text alignment */ | |
| 185 | + | /* Fix font dropdown text alignment and overflow */ | |
| 186 | 186 | select { | |
| 187 | 187 | height: 20px; | |
| 188 | 188 | line-height: 1; | |
| 189 | 189 | vertical-align: middle; | |
| 190 | - | padding: 1px 4px; | |
| 190 | + | padding: 1px 21px 1px 4px; | |
| 191 | 191 | font-size: 11px; | |
| 192 | + | max-width: 120px; | |
| 193 | + | overflow: hidden; | |
| 194 | + | text-overflow: ellipsis; | |
| 195 | + | white-space: nowrap; | |
| 192 | 196 | } | |
| 193 | 197 | ||
| 194 | 198 | select option { | |
| 195 | 199 | padding: 2px 4px; | |
| 196 | 200 | line-height: normal; | |
| 201 | + | font-family: inherit; | |
| 197 | 202 | } | |
| 198 | 203 | </style> | |
| @@ -87,7 +87,7 @@ | |||||
| 87 | title="Font Family" | 87 | title="Font Family" | ||
| 88 | > | 88 | > | ||
| 89 | {#each Object.entries(RETRO_FONTS) as [key, font] (key)} | 89 | {#each Object.entries(RETRO_FONTS) as [key, font] (key)} | ||
| 90 | - | <option value={key} style="font-family: {font.stack};">{font.name}</option> | 90 | + | <option value={key}>{font.shortName}</option> |
| 91 | {/each} | 91 | {/each} | ||
| 92 | </select> | 92 | </select> | ||
| 93 | {/if} | 93 | {/if} | ||
| @@ -182,17 +182,22 @@ | |||||
| 182 | gap: 1px; | 182 | gap: 1px; | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | - | /* Fix font dropdown text alignment */ | 185 | + | /* Fix font dropdown text alignment and overflow */ |
| 186 | select { | 186 | select { | ||
| 187 | height: 20px; | 187 | height: 20px; | ||
| 188 | line-height: 1; | 188 | line-height: 1; | ||
| 189 | vertical-align: middle; | 189 | vertical-align: middle; | ||
| 190 | - | padding: 1px 4px; | 190 | + | padding: 1px 21px 1px 4px; |
| 191 | font-size: 11px; | 191 | font-size: 11px; | ||
| 192 | + | max-width: 120px; | |||
| 193 | + | overflow: hidden; | |||
| 194 | + | text-overflow: ellipsis; | |||
| 195 | + | white-space: nowrap; | |||
| 192 | } | 196 | } | ||
| 193 | 197 | ||||
| 194 | select option { | 198 | select option { | ||
| 195 | padding: 2px 4px; | 199 | padding: 2px 4px; | ||
| 196 | line-height: normal; | 200 | line-height: normal; | ||
| 201 | + | font-family: inherit; | |||
| 197 | } | 202 | } | ||
| 198 | </style> | 203 | </style> | ||
M src/lib/states/chat.svelte.ts +29 -0
| @@ -16,9 +16,19 @@ class ChatState { | |||
| 16 | 16 | // Connection state for UI feedback | |
| 17 | 17 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | |
| 18 | 18 | ||
| 19 | + | // Mention notifications per room | |
| 20 | + | private _roomMentionCounts = $state<Record<string, number>>({}); | |
| 21 | + | ||
| 19 | 22 | // Derived public state | |
| 20 | 23 | public connectionStatus = $derived(this._connectionStatus); | |
| 21 | 24 | public isConnected = $derived(this._connectionStatus === 'connected'); | |
| 25 | + | public mentionTotal = $derived( | |
| 26 | + | Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0) | |
| 27 | + | ); | |
| 28 | + | ||
| 29 | + | getMentionTotal() { | |
| 30 | + | return this.mentionTotal; | |
| 31 | + | } | |
| 22 | 32 | ||
| 23 | 33 | /** | |
| 24 | 34 | * Get the current user | |
| @@ -34,6 +44,7 @@ class ChatState { | |||
| 34 | 44 | if (!user) { | |
| 35 | 45 | this._currentUser = null; | |
| 36 | 46 | this._connectionStatus = 'disconnected'; | |
| 47 | + | this._roomMentionCounts = {}; | |
| 37 | 48 | return; | |
| 38 | 49 | } | |
| 39 | 50 | ||
| @@ -62,6 +73,24 @@ class ChatState { | |||
| 62 | 73 | setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') { | |
| 63 | 74 | this._connectionStatus = status; | |
| 64 | 75 | } | |
| 76 | + | ||
| 77 | + | /** | |
| 78 | + | * Update mention count for a room | |
| 79 | + | */ | |
| 80 | + | setRoomMentionCount(roomId: string | null, count: number) { | |
| 81 | + | if (!roomId) return; | |
| 82 | + | const next = Math.max(0, count); | |
| 83 | + | if (this._roomMentionCounts[roomId] === next) return; | |
| 84 | + | this._roomMentionCounts = { ...this._roomMentionCounts, [roomId]: next }; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /** | |
| 88 | + | * Get mention count for a room | |
| 89 | + | */ | |
| 90 | + | getRoomMentionCount(roomId: string | null) { | |
| 91 | + | if (!roomId) return 0; | |
| 92 | + | return this._roomMentionCounts[roomId] ?? 0; | |
| 93 | + | } | |
| 65 | 94 | } | |
| 66 | 95 | ||
| 67 | 96 | export const chatState = new ChatState(); | |
| @@ -16,9 +16,19 @@ class ChatState { | |||||
| 16 | // Connection state for UI feedback | 16 | // Connection state for UI feedback | ||
| 17 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | 17 | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | ||
| 18 | 18 | ||||
| 19 | + | // Mention notifications per room | |||
| 20 | + | private _roomMentionCounts = $state<Record<string, number>>({}); | |||
| 21 | + | ||||
| 19 | // Derived public state | 22 | // Derived public state | ||
| 20 | public connectionStatus = $derived(this._connectionStatus); | 23 | public connectionStatus = $derived(this._connectionStatus); | ||
| 21 | public isConnected = $derived(this._connectionStatus === 'connected'); | 24 | public isConnected = $derived(this._connectionStatus === 'connected'); | ||
| 25 | + | public mentionTotal = $derived( | |||
| 26 | + | Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0) | |||
| 27 | + | ); | |||
| 28 | + | ||||
| 29 | + | getMentionTotal() { | |||
| 30 | + | return this.mentionTotal; | |||
| 31 | + | } | |||
| 22 | 32 | ||||
| 23 | /** | 33 | /** | ||
| 24 | * Get the current user | 34 | * Get the current user | ||
| @@ -34,6 +44,7 @@ class ChatState { | |||||
| 34 | if (!user) { | 44 | if (!user) { | ||
| 35 | this._currentUser = null; | 45 | this._currentUser = null; | ||
| 36 | this._connectionStatus = 'disconnected'; | 46 | this._connectionStatus = 'disconnected'; | ||
| 47 | + | this._roomMentionCounts = {}; | |||
| 37 | return; | 48 | return; | ||
| 38 | } | 49 | } | ||
| 39 | 50 | ||||
| @@ -62,6 +73,24 @@ class ChatState { | |||||
| 62 | setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') { | 73 | setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') { | ||
| 63 | this._connectionStatus = status; | 74 | this._connectionStatus = status; | ||
| 64 | } | 75 | } | ||
| 76 | + | ||||
| 77 | + | /** | |||
| 78 | + | * Update mention count for a room | |||
| 79 | + | */ | |||
| 80 | + | setRoomMentionCount(roomId: string | null, count: number) { | |||
| 81 | + | if (!roomId) return; | |||
| 82 | + | const next = Math.max(0, count); | |||
| 83 | + | if (this._roomMentionCounts[roomId] === next) return; | |||
| 84 | + | this._roomMentionCounts = { ...this._roomMentionCounts, [roomId]: next }; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | /** | |||
| 88 | + | * Get mention count for a room | |||
| 89 | + | */ | |||
| 90 | + | getRoomMentionCount(roomId: string | null) { | |||
| 91 | + | if (!roomId) return 0; | |||
| 92 | + | return this._roomMentionCounts[roomId] ?? 0; | |||
| 93 | + | } | |||
| 65 | } | 94 | } | ||
| 66 | 95 | ||||
| 67 | export const chatState = new ChatState(); | 96 | export const chatState = new ChatState(); | ||
M src/lib/types/text-formatting.ts +24 -4
| @@ -6,22 +6,42 @@ | |||
| 6 | 6 | export const RETRO_FONTS = { | |
| 7 | 7 | pixelated: { | |
| 8 | 8 | name: 'Pixelated MS Sans Serif', | |
| 9 | + | shortName: 'MS Sans Serif', | |
| 9 | 10 | stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' | |
| 10 | 11 | }, | |
| 11 | - | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 12 | - | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | |
| 12 | + | tahoma: { | |
| 13 | + | name: 'Tahoma', | |
| 14 | + | shortName: 'Tahoma', | |
| 15 | + | stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' | |
| 16 | + | }, | |
| 17 | + | verdana: { | |
| 18 | + | name: 'Verdana', | |
| 19 | + | shortName: 'Verdana', | |
| 20 | + | stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' | |
| 21 | + | }, | |
| 13 | 22 | comicSans: { | |
| 14 | 23 | name: 'Comic Sans MS', | |
| 24 | + | shortName: 'Comic Sans', | |
| 15 | 25 | stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' | |
| 16 | 26 | }, | |
| 17 | - | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | |
| 18 | - | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | |
| 27 | + | timesNewRoman: { | |
| 28 | + | name: 'Times New Roman', | |
| 29 | + | shortName: 'Times', | |
| 30 | + | stack: '"Times New Roman", Times, serif' | |
| 31 | + | }, | |
| 32 | + | arial: { | |
| 33 | + | name: 'Arial', | |
| 34 | + | shortName: 'Arial', | |
| 35 | + | stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' | |
| 36 | + | }, | |
| 19 | 37 | courierNew: { | |
| 20 | 38 | name: 'Courier New', | |
| 39 | + | shortName: 'Courier', | |
| 21 | 40 | stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' | |
| 22 | 41 | }, | |
| 23 | 42 | dos: { | |
| 24 | 43 | name: 'Perfect DOS VGA 437', | |
| 44 | + | shortName: 'DOS VGA', | |
| 25 | 45 | stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' | |
| 26 | 46 | } | |
| 27 | 47 | } as const; | |
| @@ -6,22 +6,42 @@ | |||||
| 6 | export const RETRO_FONTS = { | 6 | export const RETRO_FONTS = { | ||
| 7 | pixelated: { | 7 | pixelated: { | ||
| 8 | name: 'Pixelated MS Sans Serif', | 8 | name: 'Pixelated MS Sans Serif', | ||
| 9 | + | shortName: 'MS Sans Serif', | |||
| 9 | stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' | 10 | stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' | ||
| 10 | }, | 11 | }, | ||
| 11 | - | tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | 12 | + | tahoma: { |
| 12 | - | verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' }, | 13 | + | name: 'Tahoma', |
| 14 | + | shortName: 'Tahoma', | |||
| 15 | + | stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' | |||
| 16 | + | }, | |||
| 17 | + | verdana: { | |||
| 18 | + | name: 'Verdana', | |||
| 19 | + | shortName: 'Verdana', | |||
| 20 | + | stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' | |||
| 21 | + | }, | |||
| 13 | comicSans: { | 22 | comicSans: { | ||
| 14 | name: 'Comic Sans MS', | 23 | name: 'Comic Sans MS', | ||
| 24 | + | shortName: 'Comic Sans', | |||
| 15 | stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' | 25 | stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' | ||
| 16 | }, | 26 | }, | ||
| 17 | - | timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' }, | 27 | + | timesNewRoman: { |
| 18 | - | arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' }, | 28 | + | name: 'Times New Roman', |
| 29 | + | shortName: 'Times', | |||
| 30 | + | stack: '"Times New Roman", Times, serif' | |||
| 31 | + | }, | |||
| 32 | + | arial: { | |||
| 33 | + | name: 'Arial', | |||
| 34 | + | shortName: 'Arial', | |||
| 35 | + | stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' | |||
| 36 | + | }, | |||
| 19 | courierNew: { | 37 | courierNew: { | ||
| 20 | name: 'Courier New', | 38 | name: 'Courier New', | ||
| 39 | + | shortName: 'Courier', | |||
| 21 | stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' | 40 | stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' | ||
| 22 | }, | 41 | }, | ||
| 23 | dos: { | 42 | dos: { | ||
| 24 | name: 'Perfect DOS VGA 437', | 43 | name: 'Perfect DOS VGA 437', | ||
| 44 | + | shortName: 'DOS VGA', | |||
| 25 | stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' | 45 | stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' | ||
| 26 | } | 46 | } | ||
| 27 | } as const; | 47 | } as const; | ||
A src/lib/utils/mention.spec.ts +275 -0
| @@ -0,0 +1,275 @@ | |||
| 1 | + | import { describe, it, expect, beforeEach } from 'vitest'; | |
| 2 | + | import { | |
| 3 | + | MENTION_USERNAME_MAX_LENGTH, | |
| 4 | + | USERNAME_PATTERN, | |
| 5 | + | MAX_MENTION_SUGGESTIONS, | |
| 6 | + | MENTION_PICKER, | |
| 7 | + | MENTION_REGEX_PLAIN, | |
| 8 | + | MENTION_REGEX_HTML, | |
| 9 | + | isWordStart, | |
| 10 | + | findMentionContext, | |
| 11 | + | highlightMentionsPlain, | |
| 12 | + | highlightMentionsHtml, | |
| 13 | + | highlightMentionsInput, | |
| 14 | + | containsMention, | |
| 15 | + | calculatePickerHeight, | |
| 16 | + | calculatePickerWidth | |
| 17 | + | } from './mention'; | |
| 18 | + | ||
| 19 | + | describe('mention constants', () => { | |
| 20 | + | it('has correct username max length', () => { | |
| 21 | + | expect(MENTION_USERNAME_MAX_LENGTH).toBe(32); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | it('has correct max suggestions', () => { | |
| 25 | + | expect(MAX_MENTION_SUGGESTIONS).toBe(6); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | it('has correct picker dimensions', () => { | |
| 29 | + | expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32); | |
| 30 | + | expect(MENTION_PICKER.PADDING).toBe(12); | |
| 31 | + | expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44); | |
| 32 | + | expect(MENTION_PICKER.MIN_WIDTH).toBe(220); | |
| 33 | + | }); | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | describe('USERNAME_PATTERN', () => { | |
| 37 | + | it('matches valid usernames', () => { | |
| 38 | + | expect(USERNAME_PATTERN.test('alice')).toBe(true); | |
| 39 | + | expect(USERNAME_PATTERN.test('Bob123')).toBe(true); | |
| 40 | + | expect(USERNAME_PATTERN.test('user_name')).toBe(true); | |
| 41 | + | expect(USERNAME_PATTERN.test('user-name')).toBe(true); | |
| 42 | + | expect(USERNAME_PATTERN.test('A')).toBe(true); | |
| 43 | + | expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true); | |
| 44 | + | }); | |
| 45 | + | ||
| 46 | + | it('rejects invalid usernames', () => { | |
| 47 | + | expect(USERNAME_PATTERN.test('')).toBe(false); | |
| 48 | + | expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false); | |
| 49 | + | expect(USERNAME_PATTERN.test('user name')).toBe(false); | |
| 50 | + | expect(USERNAME_PATTERN.test('user.name')).toBe(false); | |
| 51 | + | expect(USERNAME_PATTERN.test('@alice')).toBe(false); | |
| 52 | + | expect(USERNAME_PATTERN.test('alice!')).toBe(false); | |
| 53 | + | }); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | describe('isWordStart', () => { | |
| 57 | + | it('returns true at position 0', () => { | |
| 58 | + | expect(isWordStart('hello', 0)).toBe(true); | |
| 59 | + | expect(isWordStart('@alice', 0)).toBe(true); | |
| 60 | + | }); | |
| 61 | + | ||
| 62 | + | it('returns true after whitespace', () => { | |
| 63 | + | expect(isWordStart('hello @alice', 6)).toBe(true); | |
| 64 | + | expect(isWordStart('hi\t@bob', 3)).toBe(true); | |
| 65 | + | expect(isWordStart('hi\n@bob', 3)).toBe(true); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | it('returns false mid-word', () => { | |
| 69 | + | expect(isWordStart('hello', 2)).toBe(false); | |
| 70 | + | expect(isWordStart('test@alice', 4)).toBe(false); | |
| 71 | + | }); | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | describe('findMentionContext', () => { | |
| 75 | + | it('finds mention at start of input', () => { | |
| 76 | + | const result = findMentionContext('@alice', 6); | |
| 77 | + | expect(result).toEqual({ atIndex: 0, query: 'alice' }); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | it('finds mention after space', () => { | |
| 81 | + | const result = findMentionContext('hello @bob', 10); | |
| 82 | + | expect(result).toEqual({ atIndex: 6, query: 'bob' }); | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | it('finds partial mention (typing in progress)', () => { | |
| 86 | + | const result = findMentionContext('hello @al', 9); | |
| 87 | + | expect(result).toEqual({ atIndex: 6, query: 'al' }); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | it('finds empty query (just @)', () => { | |
| 91 | + | const result = findMentionContext('hello @', 7); | |
| 92 | + | expect(result).toEqual({ atIndex: 6, query: '' }); | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | it('returns null when no @', () => { | |
| 96 | + | expect(findMentionContext('hello world', 11)).toBeNull(); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | it('returns null when @ is mid-word', () => { | |
| 100 | + | expect(findMentionContext('test@alice', 10)).toBeNull(); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | it('returns null when query contains space', () => { | |
| 104 | + | expect(findMentionContext('@alice bob', 10)).toBeNull(); | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | it('returns null when query exceeds max length', () => { | |
| 108 | + | const longName = 'a'.repeat(33); | |
| 109 | + | expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull(); | |
| 110 | + | }); | |
| 111 | + | ||
| 112 | + | it('returns null when query has invalid characters', () => { | |
| 113 | + | expect(findMentionContext('@alice!', 7)).toBeNull(); | |
| 114 | + | expect(findMentionContext('@alice.bob', 10)).toBeNull(); | |
| 115 | + | }); | |
| 116 | + | ||
| 117 | + | it('handles cursor in middle of mention', () => { | |
| 118 | + | const result = findMentionContext('@alice hello', 4); | |
| 119 | + | expect(result).toEqual({ atIndex: 0, query: 'ali' }); | |
| 120 | + | }); | |
| 121 | + | }); | |
| 122 | + | ||
| 123 | + | describe('MENTION_REGEX_PLAIN', () => { | |
| 124 | + | beforeEach(() => { | |
| 125 | + | // Reset regex lastIndex for global regex | |
| 126 | + | MENTION_REGEX_PLAIN.lastIndex = 0; | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | it('matches mention at start', () => { | |
| 130 | + | const matches = '@alice hello'.match(MENTION_REGEX_PLAIN); | |
| 131 | + | expect(matches).toContain('@alice'); | |
| 132 | + | }); | |
| 133 | + | ||
| 134 | + | it('matches mention after space', () => { | |
| 135 | + | const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN); | |
| 136 | + | expect(matches).toContain(' @bob'); | |
| 137 | + | }); | |
| 138 | + | ||
| 139 | + | it('matches multiple mentions', () => { | |
| 140 | + | const text = '@alice and @bob are here'; | |
| 141 | + | const matches = text.match(MENTION_REGEX_PLAIN); | |
| 142 | + | expect(matches).toHaveLength(2); | |
| 143 | + | }); | |
| 144 | + | ||
| 145 | + | it('does not match mid-word @', () => { | |
| 146 | + | const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN); | |
| 147 | + | expect(matches).toBeNull(); | |
| 148 | + | }); | |
| 149 | + | }); | |
| 150 | + | ||
| 151 | + | describe('MENTION_REGEX_HTML', () => { | |
| 152 | + | beforeEach(() => { | |
| 153 | + | MENTION_REGEX_HTML.lastIndex = 0; | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | it('matches mention after HTML tag', () => { | |
| 157 | + | const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML); | |
| 158 | + | expect(matches).toContain('>@alice'); | |
| 159 | + | }); | |
| 160 | + | ||
| 161 | + | it('matches mention at start', () => { | |
| 162 | + | const matches = '@bob said'.match(MENTION_REGEX_HTML); | |
| 163 | + | expect(matches).toContain('@bob'); | |
| 164 | + | }); | |
| 165 | + | }); | |
| 166 | + | ||
| 167 | + | describe('highlightMentionsPlain', () => { | |
| 168 | + | it('wraps mention in span', () => { | |
| 169 | + | const result = highlightMentionsPlain('@alice'); | |
| 170 | + | expect(result).toBe('<span class="mention-token">@alice</span>'); | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | it('preserves text around mention', () => { | |
| 174 | + | const result = highlightMentionsPlain('hello @bob there'); | |
| 175 | + | expect(result).toBe('hello <span class="mention-token">@bob</span> there'); | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | it('handles multiple mentions', () => { | |
| 179 | + | const result = highlightMentionsPlain('@alice and @bob'); | |
| 180 | + | expect(result).toContain('<span class="mention-token">@alice</span>'); | |
| 181 | + | expect(result).toContain('<span class="mention-token">@bob</span>'); | |
| 182 | + | }); | |
| 183 | + | ||
| 184 | + | it('does not highlight mid-word @', () => { | |
| 185 | + | const result = highlightMentionsPlain('email@test.com'); | |
| 186 | + | expect(result).toBe('email@test.com'); | |
| 187 | + | }); | |
| 188 | + | }); | |
| 189 | + | ||
| 190 | + | describe('highlightMentionsHtml', () => { | |
| 191 | + | it('wraps mention after HTML tag', () => { | |
| 192 | + | const result = highlightMentionsHtml('<p>@alice</p>'); | |
| 193 | + | expect(result).toBe('<p><span class="mention-token">@alice</span></p>'); | |
| 194 | + | }); | |
| 195 | + | ||
| 196 | + | it('handles mention at start', () => { | |
| 197 | + | const result = highlightMentionsHtml('@bob said'); | |
| 198 | + | expect(result).toBe('<span class="mention-token">@bob</span> said'); | |
| 199 | + | }); | |
| 200 | + | }); | |
| 201 | + | ||
| 202 | + | describe('highlightMentionsInput', () => { | |
| 203 | + | it('uses input-mention class', () => { | |
| 204 | + | const result = highlightMentionsInput('@alice'); | |
| 205 | + | expect(result).toBe('<span class="input-mention">@alice</span>'); | |
| 206 | + | }); | |
| 207 | + | }); | |
| 208 | + | ||
| 209 | + | describe('containsMention', () => { | |
| 210 | + | it('detects mention (case-insensitive by default)', () => { | |
| 211 | + | expect(containsMention('hello @Alice', 'alice')).toBe(true); | |
| 212 | + | expect(containsMention('hello @ALICE', 'alice')).toBe(true); | |
| 213 | + | expect(containsMention('hello @alice', 'ALICE')).toBe(true); | |
| 214 | + | }); | |
| 215 | + | ||
| 216 | + | it('respects case sensitivity when enabled', () => { | |
| 217 | + | expect(containsMention('hello @Alice', 'alice', true)).toBe(false); | |
| 218 | + | expect(containsMention('hello @alice', 'alice', true)).toBe(true); | |
| 219 | + | }); | |
| 220 | + | ||
| 221 | + | it('returns false when no mention', () => { | |
| 222 | + | expect(containsMention('hello world', 'alice')).toBe(false); | |
| 223 | + | }); | |
| 224 | + | ||
| 225 | + | it('returns false for partial match', () => { | |
| 226 | + | expect(containsMention('hello @alicexyz', 'alice')).toBe(false); | |
| 227 | + | }); | |
| 228 | + | ||
| 229 | + | it('handles special regex characters in username', () => { | |
| 230 | + | expect(containsMention('hello @user-name', 'user-name')).toBe(true); | |
| 231 | + | expect(containsMention('hello @user_name', 'user_name')).toBe(true); | |
| 232 | + | }); | |
| 233 | + | ||
| 234 | + | it('requires word boundary before @', () => { | |
| 235 | + | expect(containsMention('test@alice', 'alice')).toBe(false); | |
| 236 | + | }); | |
| 237 | + | }); | |
| 238 | + | ||
| 239 | + | describe('calculatePickerHeight', () => { | |
| 240 | + | it('returns empty state height for 0 suggestions', () => { | |
| 241 | + | expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT); | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | it('calculates height for single suggestion', () => { | |
| 245 | + | expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING); | |
| 246 | + | }); | |
| 247 | + | ||
| 248 | + | it('calculates height for multiple suggestions', () => { | |
| 249 | + | expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING); | |
| 250 | + | }); | |
| 251 | + | ||
| 252 | + | it('caps at max suggestions', () => { | |
| 253 | + | const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING; | |
| 254 | + | expect(calculatePickerHeight(10)).toBe(maxHeight); | |
| 255 | + | expect(calculatePickerHeight(100)).toBe(maxHeight); | |
| 256 | + | }); | |
| 257 | + | }); | |
| 258 | + | ||
| 259 | + | describe('calculatePickerWidth', () => { | |
| 260 | + | it('returns minimum width for small content', () => { | |
| 261 | + | expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH); | |
| 262 | + | }); | |
| 263 | + | ||
| 264 | + | it('calculates width for larger content', () => { | |
| 265 | + | const nameWidth = 150; | |
| 266 | + | const statusWidth = 50; | |
| 267 | + | const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING; | |
| 268 | + | expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected); | |
| 269 | + | }); | |
| 270 | + | ||
| 271 | + | it('rounds up to nearest integer', () => { | |
| 272 | + | const result = calculatePickerWidth(150.5, 50.3); | |
| 273 | + | expect(Number.isInteger(result)).toBe(true); | |
| 274 | + | }); | |
| 275 | + | }); | |
| @@ -0,0 +1,275 @@ | |||||
| 1 | + | import { describe, it, expect, beforeEach } from 'vitest'; | |||
| 2 | + | import { | |||
| 3 | + | MENTION_USERNAME_MAX_LENGTH, | |||
| 4 | + | USERNAME_PATTERN, | |||
| 5 | + | MAX_MENTION_SUGGESTIONS, | |||
| 6 | + | MENTION_PICKER, | |||
| 7 | + | MENTION_REGEX_PLAIN, | |||
| 8 | + | MENTION_REGEX_HTML, | |||
| 9 | + | isWordStart, | |||
| 10 | + | findMentionContext, | |||
| 11 | + | highlightMentionsPlain, | |||
| 12 | + | highlightMentionsHtml, | |||
| 13 | + | highlightMentionsInput, | |||
| 14 | + | containsMention, | |||
| 15 | + | calculatePickerHeight, | |||
| 16 | + | calculatePickerWidth | |||
| 17 | + | } from './mention'; | |||
| 18 | + | ||||
| 19 | + | describe('mention constants', () => { | |||
| 20 | + | it('has correct username max length', () => { | |||
| 21 | + | expect(MENTION_USERNAME_MAX_LENGTH).toBe(32); | |||
| 22 | + | }); | |||
| 23 | + | ||||
| 24 | + | it('has correct max suggestions', () => { | |||
| 25 | + | expect(MAX_MENTION_SUGGESTIONS).toBe(6); | |||
| 26 | + | }); | |||
| 27 | + | ||||
| 28 | + | it('has correct picker dimensions', () => { | |||
| 29 | + | expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32); | |||
| 30 | + | expect(MENTION_PICKER.PADDING).toBe(12); | |||
| 31 | + | expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44); | |||
| 32 | + | expect(MENTION_PICKER.MIN_WIDTH).toBe(220); | |||
| 33 | + | }); | |||
| 34 | + | }); | |||
| 35 | + | ||||
| 36 | + | describe('USERNAME_PATTERN', () => { | |||
| 37 | + | it('matches valid usernames', () => { | |||
| 38 | + | expect(USERNAME_PATTERN.test('alice')).toBe(true); | |||
| 39 | + | expect(USERNAME_PATTERN.test('Bob123')).toBe(true); | |||
| 40 | + | expect(USERNAME_PATTERN.test('user_name')).toBe(true); | |||
| 41 | + | expect(USERNAME_PATTERN.test('user-name')).toBe(true); | |||
| 42 | + | expect(USERNAME_PATTERN.test('A')).toBe(true); | |||
| 43 | + | expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true); | |||
| 44 | + | }); | |||
| 45 | + | ||||
| 46 | + | it('rejects invalid usernames', () => { | |||
| 47 | + | expect(USERNAME_PATTERN.test('')).toBe(false); | |||
| 48 | + | expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false); | |||
| 49 | + | expect(USERNAME_PATTERN.test('user name')).toBe(false); | |||
| 50 | + | expect(USERNAME_PATTERN.test('user.name')).toBe(false); | |||
| 51 | + | expect(USERNAME_PATTERN.test('@alice')).toBe(false); | |||
| 52 | + | expect(USERNAME_PATTERN.test('alice!')).toBe(false); | |||
| 53 | + | }); | |||
| 54 | + | }); | |||
| 55 | + | ||||
| 56 | + | describe('isWordStart', () => { | |||
| 57 | + | it('returns true at position 0', () => { | |||
| 58 | + | expect(isWordStart('hello', 0)).toBe(true); | |||
| 59 | + | expect(isWordStart('@alice', 0)).toBe(true); | |||
| 60 | + | }); | |||
| 61 | + | ||||
| 62 | + | it('returns true after whitespace', () => { | |||
| 63 | + | expect(isWordStart('hello @alice', 6)).toBe(true); | |||
| 64 | + | expect(isWordStart('hi\t@bob', 3)).toBe(true); | |||
| 65 | + | expect(isWordStart('hi\n@bob', 3)).toBe(true); | |||
| 66 | + | }); | |||
| 67 | + | ||||
| 68 | + | it('returns false mid-word', () => { | |||
| 69 | + | expect(isWordStart('hello', 2)).toBe(false); | |||
| 70 | + | expect(isWordStart('test@alice', 4)).toBe(false); | |||
| 71 | + | }); | |||
| 72 | + | }); | |||
| 73 | + | ||||
| 74 | + | describe('findMentionContext', () => { | |||
| 75 | + | it('finds mention at start of input', () => { | |||
| 76 | + | const result = findMentionContext('@alice', 6); | |||
| 77 | + | expect(result).toEqual({ atIndex: 0, query: 'alice' }); | |||
| 78 | + | }); | |||
| 79 | + | ||||
| 80 | + | it('finds mention after space', () => { | |||
| 81 | + | const result = findMentionContext('hello @bob', 10); | |||
| 82 | + | expect(result).toEqual({ atIndex: 6, query: 'bob' }); | |||
| 83 | + | }); | |||
| 84 | + | ||||
| 85 | + | it('finds partial mention (typing in progress)', () => { | |||
| 86 | + | const result = findMentionContext('hello @al', 9); | |||
| 87 | + | expect(result).toEqual({ atIndex: 6, query: 'al' }); | |||
| 88 | + | }); | |||
| 89 | + | ||||
| 90 | + | it('finds empty query (just @)', () => { | |||
| 91 | + | const result = findMentionContext('hello @', 7); | |||
| 92 | + | expect(result).toEqual({ atIndex: 6, query: '' }); | |||
| 93 | + | }); | |||
| 94 | + | ||||
| 95 | + | it('returns null when no @', () => { | |||
| 96 | + | expect(findMentionContext('hello world', 11)).toBeNull(); | |||
| 97 | + | }); | |||
| 98 | + | ||||
| 99 | + | it('returns null when @ is mid-word', () => { | |||
| 100 | + | expect(findMentionContext('test@alice', 10)).toBeNull(); | |||
| 101 | + | }); | |||
| 102 | + | ||||
| 103 | + | it('returns null when query contains space', () => { | |||
| 104 | + | expect(findMentionContext('@alice bob', 10)).toBeNull(); | |||
| 105 | + | }); | |||
| 106 | + | ||||
| 107 | + | it('returns null when query exceeds max length', () => { | |||
| 108 | + | const longName = 'a'.repeat(33); | |||
| 109 | + | expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull(); | |||
| 110 | + | }); | |||
| 111 | + | ||||
| 112 | + | it('returns null when query has invalid characters', () => { | |||
| 113 | + | expect(findMentionContext('@alice!', 7)).toBeNull(); | |||
| 114 | + | expect(findMentionContext('@alice.bob', 10)).toBeNull(); | |||
| 115 | + | }); | |||
| 116 | + | ||||
| 117 | + | it('handles cursor in middle of mention', () => { | |||
| 118 | + | const result = findMentionContext('@alice hello', 4); | |||
| 119 | + | expect(result).toEqual({ atIndex: 0, query: 'ali' }); | |||
| 120 | + | }); | |||
| 121 | + | }); | |||
| 122 | + | ||||
| 123 | + | describe('MENTION_REGEX_PLAIN', () => { | |||
| 124 | + | beforeEach(() => { | |||
| 125 | + | // Reset regex lastIndex for global regex | |||
| 126 | + | MENTION_REGEX_PLAIN.lastIndex = 0; | |||
| 127 | + | }); | |||
| 128 | + | ||||
| 129 | + | it('matches mention at start', () => { | |||
| 130 | + | const matches = '@alice hello'.match(MENTION_REGEX_PLAIN); | |||
| 131 | + | expect(matches).toContain('@alice'); | |||
| 132 | + | }); | |||
| 133 | + | ||||
| 134 | + | it('matches mention after space', () => { | |||
| 135 | + | const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN); | |||
| 136 | + | expect(matches).toContain(' @bob'); | |||
| 137 | + | }); | |||
| 138 | + | ||||
| 139 | + | it('matches multiple mentions', () => { | |||
| 140 | + | const text = '@alice and @bob are here'; | |||
| 141 | + | const matches = text.match(MENTION_REGEX_PLAIN); | |||
| 142 | + | expect(matches).toHaveLength(2); | |||
| 143 | + | }); | |||
| 144 | + | ||||
| 145 | + | it('does not match mid-word @', () => { | |||
| 146 | + | const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN); | |||
| 147 | + | expect(matches).toBeNull(); | |||
| 148 | + | }); | |||
| 149 | + | }); | |||
| 150 | + | ||||
| 151 | + | describe('MENTION_REGEX_HTML', () => { | |||
| 152 | + | beforeEach(() => { | |||
| 153 | + | MENTION_REGEX_HTML.lastIndex = 0; | |||
| 154 | + | }); | |||
| 155 | + | ||||
| 156 | + | it('matches mention after HTML tag', () => { | |||
| 157 | + | const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML); | |||
| 158 | + | expect(matches).toContain('>@alice'); | |||
| 159 | + | }); | |||
| 160 | + | ||||
| 161 | + | it('matches mention at start', () => { | |||
| 162 | + | const matches = '@bob said'.match(MENTION_REGEX_HTML); | |||
| 163 | + | expect(matches).toContain('@bob'); | |||
| 164 | + | }); | |||
| 165 | + | }); | |||
| 166 | + | ||||
| 167 | + | describe('highlightMentionsPlain', () => { | |||
| 168 | + | it('wraps mention in span', () => { | |||
| 169 | + | const result = highlightMentionsPlain('@alice'); | |||
| 170 | + | expect(result).toBe('<span class="mention-token">@alice</span>'); | |||
| 171 | + | }); | |||
| 172 | + | ||||
| 173 | + | it('preserves text around mention', () => { | |||
| 174 | + | const result = highlightMentionsPlain('hello @bob there'); | |||
| 175 | + | expect(result).toBe('hello <span class="mention-token">@bob</span> there'); | |||
| 176 | + | }); | |||
| 177 | + | ||||
| 178 | + | it('handles multiple mentions', () => { | |||
| 179 | + | const result = highlightMentionsPlain('@alice and @bob'); | |||
| 180 | + | expect(result).toContain('<span class="mention-token">@alice</span>'); | |||
| 181 | + | expect(result).toContain('<span class="mention-token">@bob</span>'); | |||
| 182 | + | }); | |||
| 183 | + | ||||
| 184 | + | it('does not highlight mid-word @', () => { | |||
| 185 | + | const result = highlightMentionsPlain('email@test.com'); | |||
| 186 | + | expect(result).toBe('email@test.com'); | |||
| 187 | + | }); | |||
| 188 | + | }); | |||
| 189 | + | ||||
| 190 | + | describe('highlightMentionsHtml', () => { | |||
| 191 | + | it('wraps mention after HTML tag', () => { | |||
| 192 | + | const result = highlightMentionsHtml('<p>@alice</p>'); | |||
| 193 | + | expect(result).toBe('<p><span class="mention-token">@alice</span></p>'); | |||
| 194 | + | }); | |||
| 195 | + | ||||
| 196 | + | it('handles mention at start', () => { | |||
| 197 | + | const result = highlightMentionsHtml('@bob said'); | |||
| 198 | + | expect(result).toBe('<span class="mention-token">@bob</span> said'); | |||
| 199 | + | }); | |||
| 200 | + | }); | |||
| 201 | + | ||||
| 202 | + | describe('highlightMentionsInput', () => { | |||
| 203 | + | it('uses input-mention class', () => { | |||
| 204 | + | const result = highlightMentionsInput('@alice'); | |||
| 205 | + | expect(result).toBe('<span class="input-mention">@alice</span>'); | |||
| 206 | + | }); | |||
| 207 | + | }); | |||
| 208 | + | ||||
| 209 | + | describe('containsMention', () => { | |||
| 210 | + | it('detects mention (case-insensitive by default)', () => { | |||
| 211 | + | expect(containsMention('hello @Alice', 'alice')).toBe(true); | |||
| 212 | + | expect(containsMention('hello @ALICE', 'alice')).toBe(true); | |||
| 213 | + | expect(containsMention('hello @alice', 'ALICE')).toBe(true); | |||
| 214 | + | }); | |||
| 215 | + | ||||
| 216 | + | it('respects case sensitivity when enabled', () => { | |||
| 217 | + | expect(containsMention('hello @Alice', 'alice', true)).toBe(false); | |||
| 218 | + | expect(containsMention('hello @alice', 'alice', true)).toBe(true); | |||
| 219 | + | }); | |||
| 220 | + | ||||
| 221 | + | it('returns false when no mention', () => { | |||
| 222 | + | expect(containsMention('hello world', 'alice')).toBe(false); | |||
| 223 | + | }); | |||
| 224 | + | ||||
| 225 | + | it('returns false for partial match', () => { | |||
| 226 | + | expect(containsMention('hello @alicexyz', 'alice')).toBe(false); | |||
| 227 | + | }); | |||
| 228 | + | ||||
| 229 | + | it('handles special regex characters in username', () => { | |||
| 230 | + | expect(containsMention('hello @user-name', 'user-name')).toBe(true); | |||
| 231 | + | expect(containsMention('hello @user_name', 'user_name')).toBe(true); | |||
| 232 | + | }); | |||
| 233 | + | ||||
| 234 | + | it('requires word boundary before @', () => { | |||
| 235 | + | expect(containsMention('test@alice', 'alice')).toBe(false); | |||
| 236 | + | }); | |||
| 237 | + | }); | |||
| 238 | + | ||||
| 239 | + | describe('calculatePickerHeight', () => { | |||
| 240 | + | it('returns empty state height for 0 suggestions', () => { | |||
| 241 | + | expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT); | |||
| 242 | + | }); | |||
| 243 | + | ||||
| 244 | + | it('calculates height for single suggestion', () => { | |||
| 245 | + | expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING); | |||
| 246 | + | }); | |||
| 247 | + | ||||
| 248 | + | it('calculates height for multiple suggestions', () => { | |||
| 249 | + | expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING); | |||
| 250 | + | }); | |||
| 251 | + | ||||
| 252 | + | it('caps at max suggestions', () => { | |||
| 253 | + | const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING; | |||
| 254 | + | expect(calculatePickerHeight(10)).toBe(maxHeight); | |||
| 255 | + | expect(calculatePickerHeight(100)).toBe(maxHeight); | |||
| 256 | + | }); | |||
| 257 | + | }); | |||
| 258 | + | ||||
| 259 | + | describe('calculatePickerWidth', () => { | |||
| 260 | + | it('returns minimum width for small content', () => { | |||
| 261 | + | expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH); | |||
| 262 | + | }); | |||
| 263 | + | ||||
| 264 | + | it('calculates width for larger content', () => { | |||
| 265 | + | const nameWidth = 150; | |||
| 266 | + | const statusWidth = 50; | |||
| 267 | + | const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING; | |||
| 268 | + | expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected); | |||
| 269 | + | }); | |||
| 270 | + | ||||
| 271 | + | it('rounds up to nearest integer', () => { | |||
| 272 | + | const result = calculatePickerWidth(150.5, 50.3); | |||
| 273 | + | expect(Number.isInteger(result)).toBe(true); | |||
| 274 | + | }); | |||
| 275 | + | }); | |||
A src/lib/utils/mention.ts +142 -0
| @@ -0,0 +1,142 @@ | |||
| 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 | + | ||
| 10 | + | export const MENTION_USERNAME_MAX_LENGTH = 32; | |
| 11 | + | ||
| 12 | + | export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/; | |
| 13 | + | ||
| 14 | + | export const MAX_MENTION_SUGGESTIONS = 6; | |
| 15 | + | ||
| 16 | + | export 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 | + | */ | |
| 32 | + | export 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 | + | */ | |
| 39 | + | export 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 | + | */ | |
| 47 | + | export 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 | + | */ | |
| 56 | + | export 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 | + | */ | |
| 85 | + | export 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 | + | */ | |
| 93 | + | export 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 | + | */ | |
| 100 | + | export 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 | + | */ | |
| 108 | + | export 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 | + | */ | |
| 122 | + | export 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 | + | */ | |
| 137 | + | export 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 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 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 | + | ||||
| 10 | + | export const MENTION_USERNAME_MAX_LENGTH = 32; | |||
| 11 | + | ||||
| 12 | + | export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/; | |||
| 13 | + | ||||
| 14 | + | export const MAX_MENTION_SUGGESTIONS = 6; | |||
| 15 | + | ||||
| 16 | + | export 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 | + | */ | |||
| 32 | + | export 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 | + | */ | |||
| 39 | + | export 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 | + | */ | |||
| 47 | + | export 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 | + | */ | |||
| 56 | + | export 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 | + | */ | |||
| 85 | + | export 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 | + | */ | |||
| 93 | + | export 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 | + | */ | |||
| 100 | + | export 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 | + | */ | |||
| 108 | + | export 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 | + | */ | |||
| 122 | + | export 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 | + | */ | |||
| 137 | + | export 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 | + | } | |||
M src/lib/utils/text-formatter.ts +7 -0
| @@ -6,6 +6,7 @@ import rehypeStringify from 'rehype-stringify'; | |||
| 6 | 6 | import type { TextStyle } from '../types/text-formatting'; | |
| 7 | 7 | import { validateTextStyle } from '../types/text-formatting'; | |
| 8 | 8 | import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; | |
| 9 | + | import { highlightMentionsHtml } from './mention'; | |
| 9 | 10 | ||
| 10 | 11 | // Define allowed HTML tags and attributes for security | |
| 11 | 12 | const ALLOWED_SCHEMA = { | |
| @@ -125,6 +126,9 @@ function preprocessFormatting(text: string): string { | |||
| 125 | 126 | }); | |
| 126 | 127 | }); | |
| 127 | 128 | ||
| 129 | + | // Note: Mention highlighting is applied after sanitization in formatText | |
| 130 | + | // to ensure it's not stripped by the sanitizer | |
| 131 | + | ||
| 128 | 132 | return processedText; | |
| 129 | 133 | } | |
| 130 | 134 | ||
| @@ -185,6 +189,9 @@ export async function formatText( | |||
| 185 | 189 | return escapeHtml(text); | |
| 186 | 190 | } | |
| 187 | 191 | ||
| 192 | + | // Apply mention highlighting after sanitization | |
| 193 | + | formattedHtml = highlightMentionsHtml(formattedHtml); | |
| 194 | + | ||
| 188 | 195 | return formattedHtml; | |
| 189 | 196 | } catch (error) { | |
| 190 | 197 | console.error('Text formatting error:', error); | |
| @@ -6,6 +6,7 @@ import rehypeStringify from 'rehype-stringify'; | |||||
| 6 | import type { TextStyle } from '../types/text-formatting'; | 6 | import type { TextStyle } from '../types/text-formatting'; | ||
| 7 | import { validateTextStyle } from '../types/text-formatting'; | 7 | import { validateTextStyle } from '../types/text-formatting'; | ||
| 8 | import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; | 8 | import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode'; | ||
| 9 | + | import { highlightMentionsHtml } from './mention'; | |||
| 9 | 10 | ||||
| 10 | // Define allowed HTML tags and attributes for security | 11 | // Define allowed HTML tags and attributes for security | ||
| 11 | const ALLOWED_SCHEMA = { | 12 | const ALLOWED_SCHEMA = { | ||
| @@ -125,6 +126,9 @@ function preprocessFormatting(text: string): string { | |||||
| 125 | }); | 126 | }); | ||
| 126 | }); | 127 | }); | ||
| 127 | 128 | ||||
| 129 | + | // Note: Mention highlighting is applied after sanitization in formatText | |||
| 130 | + | // to ensure it's not stripped by the sanitizer | |||
| 131 | + | ||||
| 128 | return processedText; | 132 | return processedText; | ||
| 129 | } | 133 | } | ||
| 130 | 134 | ||||
| @@ -185,6 +189,9 @@ export async function formatText( | |||||
| 185 | return escapeHtml(text); | 189 | return escapeHtml(text); | ||
| 186 | } | 190 | } | ||
| 187 | 191 | ||||
| 192 | + | // Apply mention highlighting after sanitization | |||
| 193 | + | formattedHtml = highlightMentionsHtml(formattedHtml); | |||
| 194 | + | ||||
| 188 | return formattedHtml; | 195 | return formattedHtml; | ||
| 189 | } catch (error) { | 196 | } catch (error) { | ||
| 190 | console.error('Text formatting error:', error); | 197 | console.error('Text formatting error:', error); | ||