feat(chat): add @mention autocomplete with highlighting and unread tracking
- Add mention picker with keyboard navigation (Arrow keys, Enter, Tab, Escape) - Highlight @mentions in messages with blue/bold styling - Track unread mentions per room with IntersectionObserver - Show mention count in title bar and browser tab - Add input overlay to highlight mentions while typing - Fix font dropdown overflow using shortName property - Add type safety to aim-login props and activeTab state
- commit
- 3f6429f91ac8494c6c6247824f60112911948e9d
- author
- Kit · · 2026-01-18 20:39 UTC
- committer
- Kit · · 2026-01-19 13:33 UTC
- parent
- 6aaf2c1
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
7 files · +783 -40
M src/lib/components/aim-login.svelte +16 -4
| @@ -15,9 +15,21 @@ | |||
| 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: initialTab = 'signin' | |
| 22 | + | } = $props<{ | |
| 23 | + | showAuth?: boolean; | |
| 24 | + | onLoginSuccess?: () => void; | |
| 25 | + | activeTab?: 'signin' | 'signup'; | |
| 26 | + | }>(); | |
| 27 | + | ||
| 28 | + | let activeTab = $state<'signin' | 'signup'>('signin'); | |
| 29 | + | ||
| 30 | + | $effect(() => { | |
| 31 | + | activeTab = initialTab; | |
| 32 | + | }); | |
| 21 | 33 | ||
| 22 | 34 | // Sign In state | |
| 23 | 35 | let siUsername = $state(''); | |
| @@ -229,7 +241,7 @@ | |||
| 229 | 241 | } | |
| 230 | 242 | } | |
| 231 | 243 | ||
| 232 | - | function switchTab(tab: string) { | |
| 244 | + | function switchTab(tab: 'signin' | 'signup') { | |
| 233 | 245 | activeTab = tab; | |
| 234 | 246 | error = ''; | |
| 235 | 247 | signupError = ''; | |
| @@ -15,9 +15,21 @@ | |||||
| 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: initialTab = 'signin' | |||
| 22 | + | } = $props<{ | |||
| 23 | + | showAuth?: boolean; | |||
| 24 | + | onLoginSuccess?: () => void; | |||
| 25 | + | activeTab?: 'signin' | 'signup'; | |||
| 26 | + | }>(); | |||
| 27 | + | ||||
| 28 | + | let activeTab = $state<'signin' | 'signup'>('signin'); | |||
| 29 | + | ||||
| 30 | + | $effect(() => { | |||
| 31 | + | activeTab = initialTab; | |||
| 32 | + | }); | |||
| 21 | 33 | ||||
| 22 | // Sign In state | 34 | // Sign In state | ||
| 23 | let siUsername = $state(''); | 35 | let siUsername = $state(''); | ||
| @@ -229,7 +241,7 @@ | |||||
| 229 | } | 241 | } | ||
| 230 | } | 242 | } | ||
| 231 | 243 | ||||
| 232 | - | function switchTab(tab: string) { | 244 | + | function switchTab(tab: 'signin' | 'signup') { |
| 233 | activeTab = tab; | 245 | activeTab = tab; | ||
| 234 | error = ''; | 246 | error = ''; | ||
| 235 | signupError = ''; | 247 | signupError = ''; | ||
M src/lib/components/chat-room.svelte +681 -28
| @@ -4,7 +4,7 @@ | |||
| 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 | 8 | import { browser } from '$app/environment'; | |
| 9 | 9 | import { draggable } from '$lib/actions/draggable'; | |
| 10 | 10 | import { resizable } from '$lib/actions/resizable'; | |
| @@ -146,11 +146,41 @@ | |||
| 146 | 146 | ||
| 147 | 147 | let currentMessage = $state(''); | |
| 148 | 148 | let inputScrollLeft = $state(0); | |
| 149 | - | let currentTextStyle = $state<TextStyle>({ | |
| 150 | - | ...DEFAULT_TEXT_STYLE, | |
| 151 | - | ...initialTextStyle, | |
| 152 | - | color: initialTextStyle.color || '#000000' | |
| 153 | - | }); | |
| 149 | + | // Initialize text style with prop value (intentionally capturing initial value only) | |
| 150 | + | let currentTextStyle = $state<TextStyle>( | |
| 151 | + | (() => { | |
| 152 | + | const style = { ...initialTextStyle }; | |
| 153 | + | return { | |
| 154 | + | ...DEFAULT_TEXT_STYLE, | |
| 155 | + | ...style, | |
| 156 | + | color: style.color || '#000000' | |
| 157 | + | }; | |
| 158 | + | })() | |
| 159 | + | ); | |
| 160 | + | ||
| 161 | + | // ============ MENTION STATE ============ | |
| 162 | + | ||
| 163 | + | const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/; | |
| 164 | + | const MAX_MENTION_SUGGESTIONS = 6; | |
| 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 = new Set<string>(); | |
| 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 mentionListContainer = $state<HTMLDivElement | null>(null); | |
| 181 | + | let mentionListStyle = $state(''); | |
| 182 | + | let mentionListInside = $state(true); | |
| 183 | + | let mentionMeasureContext: CanvasRenderingContext2D | null = null; | |
| 154 | 184 | ||
| 155 | 185 | // ============ RATE LIMITING STATE ============ | |
| 156 | 186 | ||
| @@ -198,6 +228,46 @@ | |||
| 198 | 228 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |
| 199 | 229 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |
| 200 | 230 | ||
| 231 | + | const mentionableUsers = $derived( | |
| 232 | + | onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id) | |
| 233 | + | ); | |
| 234 | + | ||
| 235 | + | const mentionSuggestions = $derived.by<SafeUser[]>(() => { | |
| 236 | + | if (!mentionOpen) return []; | |
| 237 | + | const query = mentionQuery.trim().toLowerCase(); | |
| 238 | + | const candidates = query | |
| 239 | + | ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query)) | |
| 240 | + | : mentionableUsers; | |
| 241 | + | return candidates.slice(0, MAX_MENTION_SUGGESTIONS); | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | const mentionListMaxHeight = MAX_MENTION_SUGGESTIONS * 32 + 12; | |
| 245 | + | ||
| 246 | + | // Format input message with mention highlighting (for overlay display) | |
| 247 | + | const formattedInputMessage = $derived.by(() => { | |
| 248 | + | if (!currentMessage) return ''; | |
| 249 | + | // Escape HTML entities first | |
| 250 | + | const escaped = currentMessage | |
| 251 | + | .replace(/&/g, '&') | |
| 252 | + | .replace(/</g, '<') | |
| 253 | + | .replace(/>/g, '>'); | |
| 254 | + | // Apply mention styling | |
| 255 | + | return escaped.replace( | |
| 256 | + | /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g, | |
| 257 | + | '$1<span class="input-mention">@$2</span>' | |
| 258 | + | ); | |
| 259 | + | }); | |
| 260 | + | ||
| 261 | + | // mentionUnreadCount is managed in state updates | |
| 262 | + | const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => { | |
| 263 | + | const highlights: Record<string, boolean> = {}; | |
| 264 | + | if (!currentUser) return highlights; | |
| 265 | + | for (const message of visibleMessages) { | |
| 266 | + | highlights[message.id] = shouldHighlightMention(message); | |
| 267 | + | } | |
| 268 | + | return highlights; | |
| 269 | + | }); | |
| 270 | + | ||
| 201 | 271 | // ============ EFFECTS ============ | |
| 202 | 272 | ||
| 203 | 273 | // Auto-scroll to bottom when new messages arrive | |
| @@ -222,6 +292,173 @@ | |||
| 222 | 292 | } | |
| 223 | 293 | }); | |
| 224 | 294 | ||
| 295 | + | $effect(() => { | |
| 296 | + | if (!browser) return; | |
| 297 | + | if (!baseTitle) baseTitle = document.title; | |
| 298 | + | const totalMentions = chatState.getMentionTotal(); | |
| 299 | + | if (totalMentions > 0) { | |
| 300 | + | document.title = `(${totalMentions}) ${baseTitle}`; | |
| 301 | + | } else { | |
| 302 | + | document.title = baseTitle; | |
| 303 | + | } | |
| 304 | + | }); | |
| 305 | + | ||
| 306 | + | $effect(() => { | |
| 307 | + | chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount); | |
| 308 | + | }); | |
| 309 | + | ||
| 310 | + | $effect(() => { | |
| 311 | + | if (!browser || !currentUser) { | |
| 312 | + | mentionObservedIds = new Set(); | |
| 313 | + | mentionMessageIds = new Set(); | |
| 314 | + | if (mentionUnreadCount !== 0) { | |
| 315 | + | mentionUnreadCount = 0; | |
| 316 | + | } | |
| 317 | + | // mention observer reset | |
| 318 | + | if (mentionObserver) { | |
| 319 | + | mentionObserver.disconnect(); | |
| 320 | + | mentionObserver = null; | |
| 321 | + | } | |
| 322 | + | return; | |
| 323 | + | } | |
| 324 | + | const mentionIds = new Set<string>(); | |
| 325 | + | for (const message of visibleMessages) { | |
| 326 | + | if (shouldHighlightMention(message)) { | |
| 327 | + | mentionIds.add(message.id); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | const nextMentionIds = mentionIds; | |
| 331 | + | if (!setsEqual(nextMentionIds, mentionMessageIds)) { | |
| 332 | + | mentionMessageIds = nextMentionIds; | |
| 333 | + | } | |
| 334 | + | const nextUnreadCount = Array.from(nextMentionIds).filter( | |
| 335 | + | (messageId) => !mentionObservedIds.has(messageId) | |
| 336 | + | ).length; | |
| 337 | + | // mentionObservedIds is a local Set to avoid effect loops | |
| 338 | + | if (mentionUnreadCount !== nextUnreadCount) { | |
| 339 | + | mentionUnreadCount = nextUnreadCount; | |
| 340 | + | } | |
| 341 | + | }); | |
| 342 | + | ||
| 343 | + | $effect(() => { | |
| 344 | + | const currentMentionIds = mentionMessageIds; | |
| 345 | + | if (!browser || !chatArea || !currentUser) return; | |
| 346 | + | if (mentionObserver) { | |
| 347 | + | mentionObserver.disconnect(); | |
| 348 | + | } | |
| 349 | + | mentionObserver = new IntersectionObserver( | |
| 350 | + | (entries) => { | |
| 351 | + | const updatedObserved = new Set(mentionObservedIds); | |
| 352 | + | let changed = false; | |
| 353 | + | const currentObserved = mentionObservedIds; | |
| 354 | + | const currentUnread = mentionUnreadCount; | |
| 355 | + | for (const entry of entries) { | |
| 356 | + | const target = entry.target as HTMLElement; | |
| 357 | + | const messageId = target.dataset.messageId; | |
| 358 | + | if (!messageId || !currentMentionIds.has(messageId)) continue; | |
| 359 | + | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |
| 360 | + | updatedObserved.add(messageId); | |
| 361 | + | changed = true; | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |
| 365 | + | mentionObservedIds = updatedObserved; | |
| 366 | + | const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size); | |
| 367 | + | if (currentUnread !== nextUnread) { | |
| 368 | + | mentionUnreadCount = nextUnread; | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | }, | |
| 372 | + | { root: chatArea, threshold: 0.6 } | |
| 373 | + | ); | |
| 374 | + | const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]')); | |
| 375 | + | for (const node of nodes) { | |
| 376 | + | const messageId = node.dataset.messageId; | |
| 377 | + | if (messageId && currentMentionIds.has(messageId)) { | |
| 378 | + | mentionObserver.observe(node); | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | // only observe mention messages | |
| 382 | + | // mention observer reset | |
| 383 | + | return () => { | |
| 384 | + | mentionObserver?.disconnect(); | |
| 385 | + | }; | |
| 386 | + | }); | |
| 387 | + | ||
| 388 | + | $effect(() => { | |
| 389 | + | if (!mentionOpen || mentionSuggestions.length === 0) { | |
| 390 | + | if (mentionActiveId !== null) { | |
| 391 | + | mentionActiveId = null; | |
| 392 | + | } | |
| 393 | + | return; | |
| 394 | + | } | |
| 395 | + | const currentActiveId = mentionActiveId; | |
| 396 | + | let selectedIndex = mentionSelectedIndex; | |
| 397 | + | if (selectedIndex >= mentionSuggestions.length) { | |
| 398 | + | selectedIndex = 0; | |
| 399 | + | if (mentionSelectedIndex !== 0) { | |
| 400 | + | mentionSelectedIndex = 0; | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | const active = mentionSuggestions[selectedIndex]; | |
| 404 | + | const nextActiveId = active ? getMentionListItemId(active.id) : null; | |
| 405 | + | if (currentActiveId !== nextActiveId) { | |
| 406 | + | mentionActiveId = nextActiveId; | |
| 407 | + | } | |
| 408 | + | }); | |
| 409 | + | ||
| 410 | + | $effect(() => { | |
| 411 | + | if (!mentionOpen || !messageInput) { | |
| 412 | + | if (mentionListStyle) mentionListStyle = ''; | |
| 413 | + | return; | |
| 414 | + | } | |
| 415 | + | const currentListStyle = mentionListStyle; | |
| 416 | + | const currentInside = mentionListInside; | |
| 417 | + | const inputRect = messageInput.getBoundingClientRect(); | |
| 418 | + | const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect(); | |
| 419 | + | // Use actual height: single row for empty state, otherwise based on suggestion count | |
| 420 | + | const listHeight = mentionSuggestions.length | |
| 421 | + | ? Math.min(mentionSuggestions.length, MAX_MENTION_SUGGESTIONS) * 32 + 12 | |
| 422 | + | : 44; // Single "Aucun utilisateur" row height | |
| 423 | + | const spaceBelow = containerRect | |
| 424 | + | ? containerRect.bottom - inputRect.bottom | |
| 425 | + | : window.innerHeight - inputRect.bottom; | |
| 426 | + | const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top; | |
| 427 | + | const showAbove = spaceBelow < listHeight && spaceAbove > listHeight; | |
| 428 | + | const maxNameWidth = mentionSuggestions.reduce((max, user) => { | |
| 429 | + | const width = measureMentionText(user.nickname, true, 13.6); | |
| 430 | + | return Math.max(max, width); | |
| 431 | + | }, 0); | |
| 432 | + | // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing) | |
| 433 | + | const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1; | |
| 434 | + | const paddingWidth = 68; | |
| 435 | + | const listWidth = Math.max(220, Math.ceil(maxNameWidth + statusWidth + paddingWidth)); | |
| 436 | + | if (containerRect) { | |
| 437 | + | if (!currentInside) mentionListInside = true; | |
| 438 | + | const top = showAbove | |
| 439 | + | ? inputRect.top - containerRect.top - listHeight - 6 | |
| 440 | + | : inputRect.bottom - containerRect.top + 6; | |
| 441 | + | const left = inputRect.left - containerRect.left; | |
| 442 | + | const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, left)}px; width: ${listWidth}px;`; | |
| 443 | + | if (currentListStyle !== nextStyle) { | |
| 444 | + | mentionListStyle = nextStyle; | |
| 445 | + | } | |
| 446 | + | } else { | |
| 447 | + | if (currentInside) mentionListInside = false; | |
| 448 | + | const top = showAbove ? inputRect.top - listHeight - 6 : inputRect.bottom + 6; | |
| 449 | + | const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, inputRect.left)}px; width: ${listWidth}px;`; | |
| 450 | + | if (currentListStyle !== nextStyle) { | |
| 451 | + | mentionListStyle = nextStyle; | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | if (mentionSuggestions.length === 0 && mentionActiveId !== null) { | |
| 455 | + | mentionActiveId = null; | |
| 456 | + | } | |
| 457 | + | if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) { | |
| 458 | + | mentionSelectedIndex = 0; | |
| 459 | + | } | |
| 460 | + | }); | |
| 461 | + | ||
| 225 | 462 | // ============ HELPERS ============ | |
| 226 | 463 | ||
| 227 | 464 | function resolveUser(senderId: string): SafeUser { | |
| @@ -253,6 +490,156 @@ | |||
| 253 | 490 | ||
| 254 | 491 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 255 | 492 | ||
| 493 | + | function isWordStart(value: string, index: number) { | |
| 494 | + | if (index <= 0) return true; | |
| 495 | + | return /\s/.test(value[index - 1]); | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | function setsEqual(a: Set<string>, b: Set<string>) { | |
| 499 | + | if (a.size !== b.size) return false; | |
| 500 | + | for (const value of a) { | |
| 501 | + | if (!b.has(value)) return false; | |
| 502 | + | } | |
| 503 | + | return true; | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) { | |
| 507 | + | if (!mentionMeasureContext) { | |
| 508 | + | const canvas = document.createElement('canvas'); | |
| 509 | + | mentionMeasureContext = canvas.getContext('2d'); | |
| 510 | + | } | |
| 511 | + | if (!mentionMeasureContext) return 0; | |
| 512 | + | mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`; | |
| 513 | + | return mentionMeasureContext.measureText(text).width; | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | function findMentionContext(value: string, cursorIndex: number) { | |
| 517 | + | const beforeCursor = value.slice(0, cursorIndex); | |
| 518 | + | const atIndex = beforeCursor.lastIndexOf('@'); | |
| 519 | + | if (atIndex === -1) return null; | |
| 520 | + | if (!isWordStart(value, atIndex)) return null; | |
| 521 | + | const query = value.slice(atIndex + 1, cursorIndex); | |
| 522 | + | if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null; | |
| 523 | + | if (query.length > 32) return null; | |
| 524 | + | if (query && !USERNAME_PATTERN.test(query)) return null; | |
| 525 | + | return { atIndex, query }; | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | function updateMentionState(input: HTMLInputElement) { | |
| 529 | + | if (!currentUser) { | |
| 530 | + | mentionOpen = false; | |
| 531 | + | return; | |
| 532 | + | } | |
| 533 | + | const cursorIndex = input.selectionStart ?? input.value.length; | |
| 534 | + | const context = findMentionContext(input.value, cursorIndex); | |
| 535 | + | if (!context) { | |
| 536 | + | closeMentionPicker(); | |
| 537 | + | return; | |
| 538 | + | } | |
| 539 | + | const mentionKey = `${context.atIndex}:${context.query}`; | |
| 540 | + | if (mentionDismissKey === mentionKey) { | |
| 541 | + | return; | |
| 542 | + | } | |
| 543 | + | const wasOpen = mentionOpen; | |
| 544 | + | const previousQuery = mentionQuery; | |
| 545 | + | const sameAnchor = mentionAnchorIndex === context.atIndex; | |
| 546 | + | mentionOpen = true; | |
| 547 | + | mentionQuery = context.query; | |
| 548 | + | mentionAnchorIndex = context.atIndex; | |
| 549 | + | mentionDismissKey = null; | |
| 550 | + | if (!wasOpen || !sameAnchor || previousQuery !== context.query) { | |
| 551 | + | mentionSelectedIndex = 0; | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | function registerMentionNode(node: HTMLElement) { | |
| 556 | + | if (!node) return; | |
| 557 | + | const messageId = node.dataset.messageId; | |
| 558 | + | if (!messageId) return; | |
| 559 | + | if (mentionObserver) { | |
| 560 | + | mentionObserver.observe(node); | |
| 561 | + | return; | |
| 562 | + | } | |
| 563 | + | // observer will scan after setup | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | function closeMentionPicker() { | |
| 567 | + | mentionOpen = false; | |
| 568 | + | mentionQuery = ''; | |
| 569 | + | mentionAnchorIndex = null; | |
| 570 | + | mentionSelectedIndex = 0; | |
| 571 | + | mentionActiveId = null; | |
| 572 | + | mentionDismissKey = null; | |
| 573 | + | if (messageInput) { | |
| 574 | + | messageInput.focus(); | |
| 575 | + | } | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | function clampMentionIndex(nextIndex: number) { | |
| 579 | + | if (mentionSuggestions.length === 0) return 0; | |
| 580 | + | const maxIndex = mentionSuggestions.length - 1; | |
| 581 | + | if (nextIndex < 0) return maxIndex; | |
| 582 | + | if (nextIndex > maxIndex) return 0; | |
| 583 | + | return nextIndex; | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | function applyMentionSelection(user: SafeUser) { | |
| 587 | + | if (!messageInput || mentionAnchorIndex === null) return; | |
| 588 | + | const selectionStart = messageInput.selectionStart ?? currentMessage.length; | |
| 589 | + | const before = currentMessage.slice(0, mentionAnchorIndex); | |
| 590 | + | const after = currentMessage.slice(selectionStart); | |
| 591 | + | const mentionText = `@${user.nickname} `; | |
| 592 | + | currentMessage = `${before}${mentionText}${after}`; | |
| 593 | + | void tick().then(() => { | |
| 594 | + | if (!messageInput) return; | |
| 595 | + | const cursor = before.length + mentionText.length; | |
| 596 | + | messageInput.focus(); | |
| 597 | + | messageInput.setSelectionRange(cursor, cursor); | |
| 598 | + | }); | |
| 599 | + | closeMentionPicker(); | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | function handleMentionKeydown(event: KeyboardEvent) { | |
| 603 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |
| 604 | + | if (event.key === 'ArrowDown') { | |
| 605 | + | event.preventDefault(); | |
| 606 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1); | |
| 607 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 608 | + | return; | |
| 609 | + | } | |
| 610 | + | if (event.key === 'ArrowUp') { | |
| 611 | + | event.preventDefault(); | |
| 612 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1); | |
| 613 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 614 | + | return; | |
| 615 | + | } | |
| 616 | + | if (event.key === 'Enter' || event.key === 'Tab') { | |
| 617 | + | event.preventDefault(); | |
| 618 | + | const choice = mentionSuggestions[mentionSelectedIndex]; | |
| 619 | + | if (choice) applyMentionSelection(choice); | |
| 620 | + | return; | |
| 621 | + | } | |
| 622 | + | if (event.key === 'Escape') { | |
| 623 | + | event.preventDefault(); | |
| 624 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |
| 625 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |
| 626 | + | } | |
| 627 | + | closeMentionPicker(); | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | function getMentionListItemId(userId: string) { | |
| 632 | + | return `${mentionListBoxId}-${userId}`; | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | function scrollMentionOptionIntoView() { | |
| 636 | + | if (!mentionActiveId) return; | |
| 637 | + | const option = | |
| 638 | + | document.getElementById(mentionActiveId) ?? | |
| 639 | + | mentionListContainer?.querySelector(`[id='${mentionActiveId}']`); | |
| 640 | + | option?.scrollIntoView({ block: 'nearest' }); | |
| 641 | + | } | |
| 642 | + | ||
| 256 | 643 | function updateCooldownProgress() { | |
| 257 | 644 | if (!cooldownEndTime) return; | |
| 258 | 645 | const now = Date.now(); | |
| @@ -282,6 +669,14 @@ | |||
| 282 | 669 | showChatRoom = false; | |
| 283 | 670 | } | |
| 284 | 671 | ||
| 672 | + | function shouldHighlightMention(message: EnrichedMessage) { | |
| 673 | + | if (!currentUser) return false; | |
| 674 | + | if (message.senderId === currentUser.id) return false; | |
| 675 | + | const escapedNickname = currentUser.nickname.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); | |
| 676 | + | const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedNickname})(?=$|\\s|[^a-zA-Z0-9_-])`, 'i'); | |
| 677 | + | return mentionRegex.test(message.content); | |
| 678 | + | } | |
| 679 | + | ||
| 285 | 680 | function handleInputChange(e: Event) { | |
| 286 | 681 | const input = e.target as HTMLInputElement; | |
| 287 | 682 | if (input.value.length > MAX_MESSAGE_LENGTH) { | |
| @@ -289,12 +684,51 @@ | |||
| 289 | 684 | currentMessage = input.value; | |
| 290 | 685 | } | |
| 291 | 686 | inputScrollLeft = input.scrollLeft; | |
| 687 | + | updateMentionState(input); | |
| 688 | + | if (!mentionOpen && mentionQuery) { | |
| 689 | + | mentionQuery = ''; | |
| 690 | + | } | |
| 292 | 691 | } | |
| 293 | 692 | ||
| 294 | 693 | function handleInputScroll(e: Event) { | |
| 295 | 694 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | |
| 296 | 695 | } | |
| 297 | 696 | ||
| 697 | + | function handleInputFocus() { | |
| 698 | + | if (!messageInput) return; | |
| 699 | + | updateMentionState(messageInput); | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | function handleInputClick() { | |
| 703 | + | if (!messageInput) return; | |
| 704 | + | updateMentionState(messageInput); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | function handleInputKeydown(event: KeyboardEvent) { | |
| 708 | + | if (!messageInput) return; | |
| 709 | + | if (event.key === 'Escape') { | |
| 710 | + | event.preventDefault(); | |
| 711 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |
| 712 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |
| 713 | + | } | |
| 714 | + | closeMentionPicker(); | |
| 715 | + | return; | |
| 716 | + | } | |
| 717 | + | if (mentionOpen) { | |
| 718 | + | handleMentionKeydown(event); | |
| 719 | + | return; | |
| 720 | + | } | |
| 721 | + | if (event.key === 'Enter') { | |
| 722 | + | event.preventDefault(); | |
| 723 | + | void handleSubmit(); | |
| 724 | + | } | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | function handleInputKeyup() { | |
| 728 | + | if (!messageInput) return; | |
| 729 | + | updateMentionState(messageInput); | |
| 730 | + | } | |
| 731 | + | ||
| 298 | 732 | async function handleSubmit() { | |
| 299 | 733 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | |
| 300 | 734 | ||
| @@ -439,6 +873,11 @@ | |||
| 439 | 873 | showAuth = false; | |
| 440 | 874 | } | |
| 441 | 875 | ||
| 876 | + | function handleMentionMouseDown(event: MouseEvent, user: SafeUser) { | |
| 877 | + | event.preventDefault(); | |
| 878 | + | applyMentionSelection(user); | |
| 879 | + | } | |
| 880 | + | ||
| 442 | 881 | // ============ LIFECYCLE ============ | |
| 443 | 882 | ||
| 444 | 883 | onMount(() => { | |
| @@ -520,6 +959,8 @@ | |||
| 520 | 959 | return () => { | |
| 521 | 960 | window.removeEventListener('resize', handleResize); | |
| 522 | 961 | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 962 | + | // Clean up canvas context used for text measurement | |
| 963 | + | mentionMeasureContext = null; | |
| 523 | 964 | }; | |
| 524 | 965 | }); | |
| 525 | 966 | ||
| @@ -537,6 +978,14 @@ | |||
| 537 | 978 | isCentered | |
| 538 | 979 | }); | |
| 539 | 980 | }); | |
| 981 | + | ||
| 982 | + | // Scroll selected mention option into view when selection changes | |
| 983 | + | $effect(() => { | |
| 984 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |
| 985 | + | // Track mentionSelectedIndex to trigger scroll on keyboard navigation | |
| 986 | + | const _index = mentionSelectedIndex; | |
| 987 | + | void tick().then(() => scrollMentionOptionIntoView()); | |
| 988 | + | }); | |
| 540 | 989 | </script> | |
| 541 | 990 | ||
| 542 | 991 | {#if showChatRoom} | |
| @@ -567,7 +1016,8 @@ | |||
| 567 | 1016 | class="title-bar-text" | |
| 568 | 1017 | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | |
| 569 | 1018 | > | |
| 570 | - | Pdr Aim {#if currentUser} | |
| 1019 | + | Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount}) | |
| 1020 | + | {/if}{#if currentUser} | |
| 571 | 1021 | - {currentUser.nickname}{:else} | |
| 572 | 1022 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | |
| 573 | 1023 | {#if connectionError && !isMinimized} | |
| @@ -640,6 +1090,7 @@ | |||
| 640 | 1090 | class="sunken-panel chat-area" | |
| 641 | 1091 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | |
| 642 | 1092 | onscroll={(e) => handleScroll(e)} | |
| 1093 | + | bind:this={chatArea} | |
| 643 | 1094 | > | |
| 644 | 1095 | {#if isInitialLoading} | |
| 645 | 1096 | <div class="initial-loading"> | |
| @@ -659,7 +1110,18 @@ | |||
| 659 | 1110 | </div> | |
| 660 | 1111 | {/if} | |
| 661 | 1112 | {#each visibleMessages as message (message.id)} | |
| 662 | - | <div class="message {message.type} text"> | |
| 1113 | + | <div | |
| 1114 | + | class="message {message.type} text" | |
| 1115 | + | class:mention-highlight={hasMentionHighlight[message.id]} | |
| 1116 | + | data-message-id={message.id} | |
| 1117 | + | data-mention={hasMentionHighlight[message.id] | |
| 1118 | + | ? `Mention de @${currentUser?.nickname ?? ''}` | |
| 1119 | + | : undefined} | |
| 1120 | + | aria-label={hasMentionHighlight[message.id] | |
| 1121 | + | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |
| 1122 | + | : undefined} | |
| 1123 | + | use:registerMentionNode | |
| 1124 | + | > | |
| 663 | 1125 | {#if message.type === 'emote'} | |
| 664 | 1126 | <span class="emote-text"> | |
| 665 | 1127 | <Tooltip | |
| @@ -737,7 +1199,7 @@ | |||
| 737 | 1199 | -webkit-text-fill-color: transparent; | |
| 738 | 1200 | background-clip: text; | |
| 739 | 1201 | " | |
| 740 | - | aria-hidden="true">{currentMessage}</span | |
| 1202 | + | aria-hidden="true">{@html formattedInputMessage}</span | |
| 741 | 1203 | > | |
| 742 | 1204 | <input | |
| 743 | 1205 | type="text" | |
| @@ -753,9 +1215,13 @@ | |||
| 753 | 1215 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 754 | 1216 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 755 | 1217 | : ''}" | |
| 756 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 1218 | + | onkeydown={handleInputKeydown} | |
| 1219 | + | onkeyup={handleInputKeyup} | |
| 757 | 1220 | oninput={handleInputChange} | |
| 758 | 1221 | onscroll={handleInputScroll} | |
| 1222 | + | onfocus={handleInputFocus} | |
| 1223 | + | onclick={handleInputClick} | |
| 1224 | + | bind:this={messageInput} | |
| 759 | 1225 | placeholder={cooldownEndTime | |
| 760 | 1226 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 761 | 1227 | : 'Écrivez un message...'} | |
| @@ -763,18 +1229,61 @@ | |||
| 763 | 1229 | /> | |
| 764 | 1230 | </div> | |
| 765 | 1231 | {: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 | - | /> | |
| 1232 | + | <div | |
| 1233 | + | class="mention-input-wrapper" | |
| 1234 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" | |
| 1235 | + | > | |
| 1236 | + | <!-- Text overlay with mention highlighting --> | |
| 1237 | + | <span | |
| 1238 | + | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" | |
| 1239 | + | style=" | |
| 1240 | + | position: absolute; | |
| 1241 | + | left: 3px; | |
| 1242 | + | top: 50%; | |
| 1243 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); | |
| 1244 | + | pointer-events: none; | |
| 1245 | + | white-space: nowrap; | |
| 1246 | + | font-size: {currentTextStyle.fontSize}px; | |
| 1247 | + | color: {currentTextStyle.color || '#000000'}; | |
| 1248 | + | {currentTextStyle.bold | |
| 1249 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1250 | + | ? 'font-weight: 200;' | |
| 1251 | + | : 'font-weight: 700;' | |
| 1252 | + | : ''} | |
| 1253 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |
| 1254 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 1255 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 1256 | + | : ''} | |
| 1257 | + | " | |
| 1258 | + | aria-hidden="true">{@html formattedInputMessage}</span | |
| 1259 | + | > | |
| 1260 | + | <input | |
| 1261 | + | type="text" | |
| 1262 | + | bind:value={currentMessage} | |
| 1263 | + | maxlength={MAX_MESSAGE_LENGTH} | |
| 1264 | + | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" | |
| 1265 | + | style="width: 100%; background: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold | |
| 1266 | + | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1267 | + | ? 'font-weight: 200;' | |
| 1268 | + | : 'font-weight: 700;' | |
| 1269 | + | : ''} {currentTextStyle.italic | |
| 1270 | + | ? 'font-style: italic;' | |
| 1271 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |
| 1272 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |
| 1273 | + | : ''} caret-color: {currentTextStyle.color || 'black'};" | |
| 1274 | + | onkeydown={handleInputKeydown} | |
| 1275 | + | onkeyup={handleInputKeyup} | |
| 1276 | + | oninput={handleInputChange} | |
| 1277 | + | onscroll={handleInputScroll} | |
| 1278 | + | onfocus={handleInputFocus} | |
| 1279 | + | onclick={handleInputClick} | |
| 1280 | + | bind:this={messageInput} | |
| 1281 | + | placeholder={cooldownEndTime | |
| 1282 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |
| 1283 | + | : 'Écrivez un message...'} | |
| 1284 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 1285 | + | /> | |
| 1286 | + | </div> | |
| 778 | 1287 | {/if} | |
| 779 | 1288 | <LoadingButton | |
| 780 | 1289 | onclick={handleSubmit} | |
| @@ -788,6 +1297,48 @@ | |||
| 788 | 1297 | </span> | |
| 789 | 1298 | {/if} | |
| 790 | 1299 | </div> | |
| 1300 | + | {#if mentionOpen} | |
| 1301 | + | <div | |
| 1302 | + | class="sunken-panel mention-picker" | |
| 1303 | + | class:mention-picker-portal={!mentionListInside} | |
| 1304 | + | role="listbox" | |
| 1305 | + | aria-label="Suggestions de mentions" | |
| 1306 | + | aria-activedescendant={mentionActiveId} | |
| 1307 | + | tabindex="-1" | |
| 1308 | + | style={`z-index: ${mentionListInside ? 10 : 3000}; ${mentionListStyle}`} | |
| 1309 | + | onmousedown={(event) => event.preventDefault()} | |
| 1310 | + | bind:this={mentionListContainer} | |
| 1311 | + | aria-live="polite" | |
| 1312 | + | > | |
| 1313 | + | {#if mentionSuggestions.length > 0} | |
| 1314 | + | {#each mentionSuggestions as user, index (user.id)} | |
| 1315 | + | <div | |
| 1316 | + | id={getMentionListItemId(user.id)} | |
| 1317 | + | role="option" | |
| 1318 | + | class="mention-option" | |
| 1319 | + | class:selected={index === mentionSelectedIndex} | |
| 1320 | + | aria-selected={index === mentionSelectedIndex} | |
| 1321 | + | tabindex="-1" | |
| 1322 | + | onmousedown={(event) => handleMentionMouseDown(event, user)} | |
| 1323 | + | > | |
| 1324 | + | <span class="mention-name">{user.nickname}</span> | |
| 1325 | + | <span class="mention-status {user.status}">{user.status}</span> | |
| 1326 | + | </div> | |
| 1327 | + | {/each} | |
| 1328 | + | {:else} | |
| 1329 | + | <div | |
| 1330 | + | class="mention-empty" | |
| 1331 | + | role="option" | |
| 1332 | + | aria-disabled="true" | |
| 1333 | + | aria-selected="false" | |
| 1334 | + | tabindex="-1" | |
| 1335 | + | > | |
| 1336 | + | Aucun utilisateur | |
| 1337 | + | </div> | |
| 1338 | + | {/if} | |
| 1339 | + | </div> | |
| 1340 | + | {/if} | |
| 1341 | + | ||
| 791 | 1342 | {#if cooldownEndTime} | |
| 792 | 1343 | <div | |
| 793 | 1344 | class="cooldown-progress" | |
| @@ -936,6 +1487,42 @@ | |||
| 936 | 1487 | color: #666; | |
| 937 | 1488 | } | |
| 938 | 1489 | ||
| 1490 | + | .message-content :global(.mention-token) { | |
| 1491 | + | color: #1e2a72; | |
| 1492 | + | font-weight: bold; | |
| 1493 | + | } | |
| 1494 | + | ||
| 1495 | + | /* Input mention highlighting */ | |
| 1496 | + | .mention-input-wrapper, | |
| 1497 | + | .gradient-input-wrapper { | |
| 1498 | + | flex: 1; | |
| 1499 | + | position: relative; | |
| 1500 | + | background: white; | |
| 1501 | + | overflow: hidden; | |
| 1502 | + | } | |
| 1503 | + | ||
| 1504 | + | .mention-text-overlay, | |
| 1505 | + | .gradient-text-overlay { | |
| 1506 | + | position: absolute; | |
| 1507 | + | left: 3px; | |
| 1508 | + | top: 50%; | |
| 1509 | + | pointer-events: none; | |
| 1510 | + | white-space: nowrap; | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | .mention-text-overlay :global(.input-mention), | |
| 1514 | + | .gradient-text-overlay :global(.input-mention) { | |
| 1515 | + | color: #1e2a72; | |
| 1516 | + | font-weight: bold; | |
| 1517 | + | -webkit-text-fill-color: #1e2a72; | |
| 1518 | + | } | |
| 1519 | + | ||
| 1520 | + | /* Force input text to be transparent when using overlay */ | |
| 1521 | + | .mention-overlay-input { | |
| 1522 | + | color: transparent !important; | |
| 1523 | + | -webkit-text-fill-color: transparent !important; | |
| 1524 | + | } | |
| 1525 | + | ||
| 939 | 1526 | .section-header { | |
| 940 | 1527 | margin: 0 0 0.25rem 0; | |
| 941 | 1528 | font-weight: bold; | |
| @@ -1000,8 +1587,7 @@ | |||
| 1000 | 1587 | z-index: 2; | |
| 1001 | 1588 | } | |
| 1002 | 1589 | ||
| 1003 | - | .input-container input:disabled, | |
| 1004 | - | .input-container { | |
| 1590 | + | .input-container input:disabled { | |
| 1005 | 1591 | opacity: 0.7; | |
| 1006 | 1592 | background-color: rgba(128, 128, 128, 0.1); | |
| 1007 | 1593 | cursor: not-allowed; | |
| @@ -1160,10 +1746,6 @@ | |||
| 1160 | 1746 | font-size: 0.875rem; | |
| 1161 | 1747 | } | |
| 1162 | 1748 | ||
| 1163 | - | .error-banner small { | |
| 1164 | - | color: #666; | |
| 1165 | - | } | |
| 1166 | - | ||
| 1167 | 1749 | .rate-limit-warning { | |
| 1168 | 1750 | background: #fff3e0; | |
| 1169 | 1751 | color: #e65100; | |
| @@ -1198,6 +1780,77 @@ | |||
| 1198 | 1780 | } | |
| 1199 | 1781 | } | |
| 1200 | 1782 | ||
| 1783 | + | .mention-picker { | |
| 1784 | + | position: absolute; | |
| 1785 | + | padding: 0.35rem; | |
| 1786 | + | max-height: 220px; | |
| 1787 | + | overflow-y: auto; | |
| 1788 | + | background: #fffef5; | |
| 1789 | + | border: 2px inset #dfdfdf; | |
| 1790 | + | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15); | |
| 1791 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |
| 1792 | + | font-size: 0.85rem; | |
| 1793 | + | z-index: 10; | |
| 1794 | + | min-width: 220px; | |
| 1795 | + | } | |
| 1796 | + | ||
| 1797 | + | .mention-picker-portal { | |
| 1798 | + | position: fixed; | |
| 1799 | + | } | |
| 1800 | + | ||
| 1801 | + | .mention-option { | |
| 1802 | + | display: flex; | |
| 1803 | + | align-items: center; | |
| 1804 | + | justify-content: space-between; | |
| 1805 | + | gap: 0.5rem; | |
| 1806 | + | padding: 0.25rem 0.4rem; | |
| 1807 | + | border: 1px solid transparent; | |
| 1808 | + | cursor: pointer; | |
| 1809 | + | } | |
| 1810 | + | ||
| 1811 | + | .mention-option.selected { | |
| 1812 | + | background: #dbe8ff; | |
| 1813 | + | border-color: #2d31a6; | |
| 1814 | + | } | |
| 1815 | + | ||
| 1816 | + | .mention-name { | |
| 1817 | + | font-weight: bold; | |
| 1818 | + | color: #1e2a72; | |
| 1819 | + | } | |
| 1820 | + | ||
| 1821 | + | .mention-status { | |
| 1822 | + | font-size: 0.7rem; | |
| 1823 | + | text-transform: uppercase; | |
| 1824 | + | letter-spacing: 0.05em; | |
| 1825 | + | color: #5b5b5b; | |
| 1826 | + | } | |
| 1827 | + | ||
| 1828 | + | .mention-status.online { | |
| 1829 | + | color: #1f7a1f; | |
| 1830 | + | } | |
| 1831 | + | ||
| 1832 | + | .mention-status.away, | |
| 1833 | + | .mention-status.busy { | |
| 1834 | + | color: #b06a00; | |
| 1835 | + | } | |
| 1836 | + | ||
| 1837 | + | .mention-status.offline { | |
| 1838 | + | color: #8a8a8a; | |
| 1839 | + | } | |
| 1840 | + | ||
| 1841 | + | .mention-empty { | |
| 1842 | + | padding: 0.35rem 0.5rem; | |
| 1843 | + | color: #777; | |
| 1844 | + | font-style: italic; | |
| 1845 | + | } | |
| 1846 | + | ||
| 1847 | + | .message.mention-highlight { | |
| 1848 | + | background: rgba(255, 237, 186, 0.45); | |
| 1849 | + | border-radius: 4px; | |
| 1850 | + | padding: 0.2rem 0.35rem; | |
| 1851 | + | box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2); | |
| 1852 | + | } | |
| 1853 | + | ||
| 1201 | 1854 | .cooldown-progress { | |
| 1202 | 1855 | position: absolute; | |
| 1203 | 1856 | bottom: 0; | |
| @@ -4,7 +4,7 @@ | |||||
| 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 { browser } from '$app/environment'; | 8 | import { browser } from '$app/environment'; | ||
| 9 | import { draggable } from '$lib/actions/draggable'; | 9 | import { draggable } from '$lib/actions/draggable'; | ||
| 10 | import { resizable } from '$lib/actions/resizable'; | 10 | import { resizable } from '$lib/actions/resizable'; | ||
| @@ -146,11 +146,41 @@ | |||||
| 146 | 146 | ||||
| 147 | let currentMessage = $state(''); | 147 | let currentMessage = $state(''); | ||
| 148 | let inputScrollLeft = $state(0); | 148 | let inputScrollLeft = $state(0); | ||
| 149 | - | let currentTextStyle = $state<TextStyle>({ | 149 | + | // Initialize text style with prop value (intentionally capturing initial value only) |
| 150 | - | ...DEFAULT_TEXT_STYLE, | 150 | + | let currentTextStyle = $state<TextStyle>( |
| 151 | - | ...initialTextStyle, | 151 | + | (() => { |
| 152 | - | color: initialTextStyle.color || '#000000' | 152 | + | const style = { ...initialTextStyle }; |
| 153 | - | }); | 153 | + | return { |
| 154 | + | ...DEFAULT_TEXT_STYLE, | |||
| 155 | + | ...style, | |||
| 156 | + | color: style.color || '#000000' | |||
| 157 | + | }; | |||
| 158 | + | })() | |||
| 159 | + | ); | |||
| 160 | + | ||||
| 161 | + | // ============ MENTION STATE ============ | |||
| 162 | + | ||||
| 163 | + | const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{1,32}$/; | |||
| 164 | + | const MAX_MENTION_SUGGESTIONS = 6; | |||
| 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 = new Set<string>(); | |||
| 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 mentionListContainer = $state<HTMLDivElement | null>(null); | |||
| 181 | + | let mentionListStyle = $state(''); | |||
| 182 | + | let mentionListInside = $state(true); | |||
| 183 | + | let mentionMeasureContext: CanvasRenderingContext2D | null = null; | |||
| 154 | 184 | ||||
| 155 | // ============ RATE LIMITING STATE ============ | 185 | // ============ RATE LIMITING STATE ============ | ||
| 156 | 186 | ||||
| @@ -198,6 +228,46 @@ | |||||
| 198 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | 228 | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | ||
| 199 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | 229 | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | ||
| 200 | 230 | ||||
| 231 | + | const mentionableUsers = $derived( | |||
| 232 | + | onlineUsers.filter((user) => !currentUser || user.id !== currentUser.id) | |||
| 233 | + | ); | |||
| 234 | + | ||||
| 235 | + | const mentionSuggestions = $derived.by<SafeUser[]>(() => { | |||
| 236 | + | if (!mentionOpen) return []; | |||
| 237 | + | const query = mentionQuery.trim().toLowerCase(); | |||
| 238 | + | const candidates = query | |||
| 239 | + | ? mentionableUsers.filter((user) => user.nickname.toLowerCase().startsWith(query)) | |||
| 240 | + | : mentionableUsers; | |||
| 241 | + | return candidates.slice(0, MAX_MENTION_SUGGESTIONS); | |||
| 242 | + | }); | |||
| 243 | + | ||||
| 244 | + | const mentionListMaxHeight = MAX_MENTION_SUGGESTIONS * 32 + 12; | |||
| 245 | + | ||||
| 246 | + | // Format input message with mention highlighting (for overlay display) | |||
| 247 | + | const formattedInputMessage = $derived.by(() => { | |||
| 248 | + | if (!currentMessage) return ''; | |||
| 249 | + | // Escape HTML entities first | |||
| 250 | + | const escaped = currentMessage | |||
| 251 | + | .replace(/&/g, '&') | |||
| 252 | + | .replace(/</g, '<') | |||
| 253 | + | .replace(/>/g, '>'); | |||
| 254 | + | // Apply mention styling | |||
| 255 | + | return escaped.replace( | |||
| 256 | + | /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g, | |||
| 257 | + | '$1<span class="input-mention">@$2</span>' | |||
| 258 | + | ); | |||
| 259 | + | }); | |||
| 260 | + | ||||
| 261 | + | // mentionUnreadCount is managed in state updates | |||
| 262 | + | const hasMentionHighlight = $derived.by<Record<string, boolean>>(() => { | |||
| 263 | + | const highlights: Record<string, boolean> = {}; | |||
| 264 | + | if (!currentUser) return highlights; | |||
| 265 | + | for (const message of visibleMessages) { | |||
| 266 | + | highlights[message.id] = shouldHighlightMention(message); | |||
| 267 | + | } | |||
| 268 | + | return highlights; | |||
| 269 | + | }); | |||
| 270 | + | ||||
| 201 | // ============ EFFECTS ============ | 271 | // ============ EFFECTS ============ | ||
| 202 | 272 | ||||
| 203 | // Auto-scroll to bottom when new messages arrive | 273 | // Auto-scroll to bottom when new messages arrive | ||
| @@ -222,6 +292,173 @@ | |||||
| 222 | } | 292 | } | ||
| 223 | }); | 293 | }); | ||
| 224 | 294 | ||||
| 295 | + | $effect(() => { | |||
| 296 | + | if (!browser) return; | |||
| 297 | + | if (!baseTitle) baseTitle = document.title; | |||
| 298 | + | const totalMentions = chatState.getMentionTotal(); | |||
| 299 | + | if (totalMentions > 0) { | |||
| 300 | + | document.title = `(${totalMentions}) ${baseTitle}`; | |||
| 301 | + | } else { | |||
| 302 | + | document.title = baseTitle; | |||
| 303 | + | } | |||
| 304 | + | }); | |||
| 305 | + | ||||
| 306 | + | $effect(() => { | |||
| 307 | + | chatState.setRoomMentionCount(roomId ?? null, mentionUnreadCount); | |||
| 308 | + | }); | |||
| 309 | + | ||||
| 310 | + | $effect(() => { | |||
| 311 | + | if (!browser || !currentUser) { | |||
| 312 | + | mentionObservedIds = new Set(); | |||
| 313 | + | mentionMessageIds = new Set(); | |||
| 314 | + | if (mentionUnreadCount !== 0) { | |||
| 315 | + | mentionUnreadCount = 0; | |||
| 316 | + | } | |||
| 317 | + | // mention observer reset | |||
| 318 | + | if (mentionObserver) { | |||
| 319 | + | mentionObserver.disconnect(); | |||
| 320 | + | mentionObserver = null; | |||
| 321 | + | } | |||
| 322 | + | return; | |||
| 323 | + | } | |||
| 324 | + | const mentionIds = new Set<string>(); | |||
| 325 | + | for (const message of visibleMessages) { | |||
| 326 | + | if (shouldHighlightMention(message)) { | |||
| 327 | + | mentionIds.add(message.id); | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | const nextMentionIds = mentionIds; | |||
| 331 | + | if (!setsEqual(nextMentionIds, mentionMessageIds)) { | |||
| 332 | + | mentionMessageIds = nextMentionIds; | |||
| 333 | + | } | |||
| 334 | + | const nextUnreadCount = Array.from(nextMentionIds).filter( | |||
| 335 | + | (messageId) => !mentionObservedIds.has(messageId) | |||
| 336 | + | ).length; | |||
| 337 | + | // mentionObservedIds is a local Set to avoid effect loops | |||
| 338 | + | if (mentionUnreadCount !== nextUnreadCount) { | |||
| 339 | + | mentionUnreadCount = nextUnreadCount; | |||
| 340 | + | } | |||
| 341 | + | }); | |||
| 342 | + | ||||
| 343 | + | $effect(() => { | |||
| 344 | + | const currentMentionIds = mentionMessageIds; | |||
| 345 | + | if (!browser || !chatArea || !currentUser) return; | |||
| 346 | + | if (mentionObserver) { | |||
| 347 | + | mentionObserver.disconnect(); | |||
| 348 | + | } | |||
| 349 | + | mentionObserver = new IntersectionObserver( | |||
| 350 | + | (entries) => { | |||
| 351 | + | const updatedObserved = new Set(mentionObservedIds); | |||
| 352 | + | let changed = false; | |||
| 353 | + | const currentObserved = mentionObservedIds; | |||
| 354 | + | const currentUnread = mentionUnreadCount; | |||
| 355 | + | for (const entry of entries) { | |||
| 356 | + | const target = entry.target as HTMLElement; | |||
| 357 | + | const messageId = target.dataset.messageId; | |||
| 358 | + | if (!messageId || !currentMentionIds.has(messageId)) continue; | |||
| 359 | + | if (entry.isIntersecting && !updatedObserved.has(messageId)) { | |||
| 360 | + | updatedObserved.add(messageId); | |||
| 361 | + | changed = true; | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | if (changed && !setsEqual(updatedObserved, currentObserved)) { | |||
| 365 | + | mentionObservedIds = updatedObserved; | |||
| 366 | + | const nextUnread = Math.max(0, currentMentionIds.size - updatedObserved.size); | |||
| 367 | + | if (currentUnread !== nextUnread) { | |||
| 368 | + | mentionUnreadCount = nextUnread; | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | }, | |||
| 372 | + | { root: chatArea, threshold: 0.6 } | |||
| 373 | + | ); | |||
| 374 | + | const nodes = Array.from(chatArea.querySelectorAll<HTMLElement>('[data-message-id]')); | |||
| 375 | + | for (const node of nodes) { | |||
| 376 | + | const messageId = node.dataset.messageId; | |||
| 377 | + | if (messageId && currentMentionIds.has(messageId)) { | |||
| 378 | + | mentionObserver.observe(node); | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | // only observe mention messages | |||
| 382 | + | // mention observer reset | |||
| 383 | + | return () => { | |||
| 384 | + | mentionObserver?.disconnect(); | |||
| 385 | + | }; | |||
| 386 | + | }); | |||
| 387 | + | ||||
| 388 | + | $effect(() => { | |||
| 389 | + | if (!mentionOpen || mentionSuggestions.length === 0) { | |||
| 390 | + | if (mentionActiveId !== null) { | |||
| 391 | + | mentionActiveId = null; | |||
| 392 | + | } | |||
| 393 | + | return; | |||
| 394 | + | } | |||
| 395 | + | const currentActiveId = mentionActiveId; | |||
| 396 | + | let selectedIndex = mentionSelectedIndex; | |||
| 397 | + | if (selectedIndex >= mentionSuggestions.length) { | |||
| 398 | + | selectedIndex = 0; | |||
| 399 | + | if (mentionSelectedIndex !== 0) { | |||
| 400 | + | mentionSelectedIndex = 0; | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | const active = mentionSuggestions[selectedIndex]; | |||
| 404 | + | const nextActiveId = active ? getMentionListItemId(active.id) : null; | |||
| 405 | + | if (currentActiveId !== nextActiveId) { | |||
| 406 | + | mentionActiveId = nextActiveId; | |||
| 407 | + | } | |||
| 408 | + | }); | |||
| 409 | + | ||||
| 410 | + | $effect(() => { | |||
| 411 | + | if (!mentionOpen || !messageInput) { | |||
| 412 | + | if (mentionListStyle) mentionListStyle = ''; | |||
| 413 | + | return; | |||
| 414 | + | } | |||
| 415 | + | const currentListStyle = mentionListStyle; | |||
| 416 | + | const currentInside = mentionListInside; | |||
| 417 | + | const inputRect = messageInput.getBoundingClientRect(); | |||
| 418 | + | const containerRect = messageInput.closest('.chat-container')?.getBoundingClientRect(); | |||
| 419 | + | // Use actual height: single row for empty state, otherwise based on suggestion count | |||
| 420 | + | const listHeight = mentionSuggestions.length | |||
| 421 | + | ? Math.min(mentionSuggestions.length, MAX_MENTION_SUGGESTIONS) * 32 + 12 | |||
| 422 | + | : 44; // Single "Aucun utilisateur" row height | |||
| 423 | + | const spaceBelow = containerRect | |||
| 424 | + | ? containerRect.bottom - inputRect.bottom | |||
| 425 | + | : window.innerHeight - inputRect.bottom; | |||
| 426 | + | const spaceAbove = containerRect ? inputRect.top - containerRect.top : inputRect.top; | |||
| 427 | + | const showAbove = spaceBelow < listHeight && spaceAbove > listHeight; | |||
| 428 | + | const maxNameWidth = mentionSuggestions.reduce((max, user) => { | |||
| 429 | + | const width = measureMentionText(user.nickname, true, 13.6); | |||
| 430 | + | return Math.max(max, width); | |||
| 431 | + | }, 0); | |||
| 432 | + | // Status uses 0.7rem (11.2px) font size and uppercase (add ~10% for letter-spacing) | |||
| 433 | + | const statusWidth = measureMentionText('OFFLINE', false, 11.2) * 1.1; | |||
| 434 | + | const paddingWidth = 68; | |||
| 435 | + | const listWidth = Math.max(220, Math.ceil(maxNameWidth + statusWidth + paddingWidth)); | |||
| 436 | + | if (containerRect) { | |||
| 437 | + | if (!currentInside) mentionListInside = true; | |||
| 438 | + | const top = showAbove | |||
| 439 | + | ? inputRect.top - containerRect.top - listHeight - 6 | |||
| 440 | + | : inputRect.bottom - containerRect.top + 6; | |||
| 441 | + | const left = inputRect.left - containerRect.left; | |||
| 442 | + | const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, left)}px; width: ${listWidth}px;`; | |||
| 443 | + | if (currentListStyle !== nextStyle) { | |||
| 444 | + | mentionListStyle = nextStyle; | |||
| 445 | + | } | |||
| 446 | + | } else { | |||
| 447 | + | if (currentInside) mentionListInside = false; | |||
| 448 | + | const top = showAbove ? inputRect.top - listHeight - 6 : inputRect.bottom + 6; | |||
| 449 | + | const nextStyle = `top: ${Math.max(8, top)}px; left: ${Math.max(8, inputRect.left)}px; width: ${listWidth}px;`; | |||
| 450 | + | if (currentListStyle !== nextStyle) { | |||
| 451 | + | mentionListStyle = nextStyle; | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | if (mentionSuggestions.length === 0 && mentionActiveId !== null) { | |||
| 455 | + | mentionActiveId = null; | |||
| 456 | + | } | |||
| 457 | + | if (mentionSelectedIndex >= mentionSuggestions.length && mentionSuggestions.length > 0) { | |||
| 458 | + | mentionSelectedIndex = 0; | |||
| 459 | + | } | |||
| 460 | + | }); | |||
| 461 | + | ||||
| 225 | // ============ HELPERS ============ | 462 | // ============ HELPERS ============ | ||
| 226 | 463 | ||||
| 227 | function resolveUser(senderId: string): SafeUser { | 464 | function resolveUser(senderId: string): SafeUser { | ||
| @@ -253,6 +490,156 @@ | |||||
| 253 | 490 | ||||
| 254 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | 491 | const debouncedSaveWindowState = debounce(saveWindowState, 300); | ||
| 255 | 492 | ||||
| 493 | + | function isWordStart(value: string, index: number) { | |||
| 494 | + | if (index <= 0) return true; | |||
| 495 | + | return /\s/.test(value[index - 1]); | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | function setsEqual(a: Set<string>, b: Set<string>) { | |||
| 499 | + | if (a.size !== b.size) return false; | |||
| 500 | + | for (const value of a) { | |||
| 501 | + | if (!b.has(value)) return false; | |||
| 502 | + | } | |||
| 503 | + | return true; | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | function measureMentionText(text: string, bold: boolean = true, fontSizePx: number = 13.6) { | |||
| 507 | + | if (!mentionMeasureContext) { | |||
| 508 | + | const canvas = document.createElement('canvas'); | |||
| 509 | + | mentionMeasureContext = canvas.getContext('2d'); | |||
| 510 | + | } | |||
| 511 | + | if (!mentionMeasureContext) return 0; | |||
| 512 | + | mentionMeasureContext.font = `${bold ? 'bold ' : ''}${fontSizePx}px 'Pixelated MS Sans Serif', Tahoma, sans-serif`; | |||
| 513 | + | return mentionMeasureContext.measureText(text).width; | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | function findMentionContext(value: string, cursorIndex: number) { | |||
| 517 | + | const beforeCursor = value.slice(0, cursorIndex); | |||
| 518 | + | const atIndex = beforeCursor.lastIndexOf('@'); | |||
| 519 | + | if (atIndex === -1) return null; | |||
| 520 | + | if (!isWordStart(value, atIndex)) return null; | |||
| 521 | + | const query = value.slice(atIndex + 1, cursorIndex); | |||
| 522 | + | if (query.includes(' ') || query.includes('\n') || query.includes('\t')) return null; | |||
| 523 | + | if (query.length > 32) return null; | |||
| 524 | + | if (query && !USERNAME_PATTERN.test(query)) return null; | |||
| 525 | + | return { atIndex, query }; | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | function updateMentionState(input: HTMLInputElement) { | |||
| 529 | + | if (!currentUser) { | |||
| 530 | + | mentionOpen = false; | |||
| 531 | + | return; | |||
| 532 | + | } | |||
| 533 | + | const cursorIndex = input.selectionStart ?? input.value.length; | |||
| 534 | + | const context = findMentionContext(input.value, cursorIndex); | |||
| 535 | + | if (!context) { | |||
| 536 | + | closeMentionPicker(); | |||
| 537 | + | return; | |||
| 538 | + | } | |||
| 539 | + | const mentionKey = `${context.atIndex}:${context.query}`; | |||
| 540 | + | if (mentionDismissKey === mentionKey) { | |||
| 541 | + | return; | |||
| 542 | + | } | |||
| 543 | + | const wasOpen = mentionOpen; | |||
| 544 | + | const previousQuery = mentionQuery; | |||
| 545 | + | const sameAnchor = mentionAnchorIndex === context.atIndex; | |||
| 546 | + | mentionOpen = true; | |||
| 547 | + | mentionQuery = context.query; | |||
| 548 | + | mentionAnchorIndex = context.atIndex; | |||
| 549 | + | mentionDismissKey = null; | |||
| 550 | + | if (!wasOpen || !sameAnchor || previousQuery !== context.query) { | |||
| 551 | + | mentionSelectedIndex = 0; | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | function registerMentionNode(node: HTMLElement) { | |||
| 556 | + | if (!node) return; | |||
| 557 | + | const messageId = node.dataset.messageId; | |||
| 558 | + | if (!messageId) return; | |||
| 559 | + | if (mentionObserver) { | |||
| 560 | + | mentionObserver.observe(node); | |||
| 561 | + | return; | |||
| 562 | + | } | |||
| 563 | + | // observer will scan after setup | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | function closeMentionPicker() { | |||
| 567 | + | mentionOpen = false; | |||
| 568 | + | mentionQuery = ''; | |||
| 569 | + | mentionAnchorIndex = null; | |||
| 570 | + | mentionSelectedIndex = 0; | |||
| 571 | + | mentionActiveId = null; | |||
| 572 | + | mentionDismissKey = null; | |||
| 573 | + | if (messageInput) { | |||
| 574 | + | messageInput.focus(); | |||
| 575 | + | } | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | function clampMentionIndex(nextIndex: number) { | |||
| 579 | + | if (mentionSuggestions.length === 0) return 0; | |||
| 580 | + | const maxIndex = mentionSuggestions.length - 1; | |||
| 581 | + | if (nextIndex < 0) return maxIndex; | |||
| 582 | + | if (nextIndex > maxIndex) return 0; | |||
| 583 | + | return nextIndex; | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | function applyMentionSelection(user: SafeUser) { | |||
| 587 | + | if (!messageInput || mentionAnchorIndex === null) return; | |||
| 588 | + | const selectionStart = messageInput.selectionStart ?? currentMessage.length; | |||
| 589 | + | const before = currentMessage.slice(0, mentionAnchorIndex); | |||
| 590 | + | const after = currentMessage.slice(selectionStart); | |||
| 591 | + | const mentionText = `@${user.nickname} `; | |||
| 592 | + | currentMessage = `${before}${mentionText}${after}`; | |||
| 593 | + | void tick().then(() => { | |||
| 594 | + | if (!messageInput) return; | |||
| 595 | + | const cursor = before.length + mentionText.length; | |||
| 596 | + | messageInput.focus(); | |||
| 597 | + | messageInput.setSelectionRange(cursor, cursor); | |||
| 598 | + | }); | |||
| 599 | + | closeMentionPicker(); | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | function handleMentionKeydown(event: KeyboardEvent) { | |||
| 603 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |||
| 604 | + | if (event.key === 'ArrowDown') { | |||
| 605 | + | event.preventDefault(); | |||
| 606 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex + 1); | |||
| 607 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 608 | + | return; | |||
| 609 | + | } | |||
| 610 | + | if (event.key === 'ArrowUp') { | |||
| 611 | + | event.preventDefault(); | |||
| 612 | + | mentionSelectedIndex = clampMentionIndex(mentionSelectedIndex - 1); | |||
| 613 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 614 | + | return; | |||
| 615 | + | } | |||
| 616 | + | if (event.key === 'Enter' || event.key === 'Tab') { | |||
| 617 | + | event.preventDefault(); | |||
| 618 | + | const choice = mentionSuggestions[mentionSelectedIndex]; | |||
| 619 | + | if (choice) applyMentionSelection(choice); | |||
| 620 | + | return; | |||
| 621 | + | } | |||
| 622 | + | if (event.key === 'Escape') { | |||
| 623 | + | event.preventDefault(); | |||
| 624 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |||
| 625 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |||
| 626 | + | } | |||
| 627 | + | closeMentionPicker(); | |||
| 628 | + | } | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | function getMentionListItemId(userId: string) { | |||
| 632 | + | return `${mentionListBoxId}-${userId}`; | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | function scrollMentionOptionIntoView() { | |||
| 636 | + | if (!mentionActiveId) return; | |||
| 637 | + | const option = | |||
| 638 | + | document.getElementById(mentionActiveId) ?? | |||
| 639 | + | mentionListContainer?.querySelector(`[id='${mentionActiveId}']`); | |||
| 640 | + | option?.scrollIntoView({ block: 'nearest' }); | |||
| 641 | + | } | |||
| 642 | + | ||||
| 256 | function updateCooldownProgress() { | 643 | function updateCooldownProgress() { | ||
| 257 | if (!cooldownEndTime) return; | 644 | if (!cooldownEndTime) return; | ||
| 258 | const now = Date.now(); | 645 | const now = Date.now(); | ||
| @@ -282,6 +669,14 @@ | |||||
| 282 | showChatRoom = false; | 669 | showChatRoom = false; | ||
| 283 | } | 670 | } | ||
| 284 | 671 | ||||
| 672 | + | function shouldHighlightMention(message: EnrichedMessage) { | |||
| 673 | + | if (!currentUser) return false; | |||
| 674 | + | if (message.senderId === currentUser.id) return false; | |||
| 675 | + | const escapedNickname = currentUser.nickname.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); | |||
| 676 | + | const mentionRegex = new RegExp(`(?:^|\\s)@(${escapedNickname})(?=$|\\s|[^a-zA-Z0-9_-])`, 'i'); | |||
| 677 | + | return mentionRegex.test(message.content); | |||
| 678 | + | } | |||
| 679 | + | ||||
| 285 | function handleInputChange(e: Event) { | 680 | function handleInputChange(e: Event) { | ||
| 286 | const input = e.target as HTMLInputElement; | 681 | const input = e.target as HTMLInputElement; | ||
| 287 | if (input.value.length > MAX_MESSAGE_LENGTH) { | 682 | if (input.value.length > MAX_MESSAGE_LENGTH) { | ||
| @@ -289,12 +684,51 @@ | |||||
| 289 | currentMessage = input.value; | 684 | currentMessage = input.value; | ||
| 290 | } | 685 | } | ||
| 291 | inputScrollLeft = input.scrollLeft; | 686 | inputScrollLeft = input.scrollLeft; | ||
| 687 | + | updateMentionState(input); | |||
| 688 | + | if (!mentionOpen && mentionQuery) { | |||
| 689 | + | mentionQuery = ''; | |||
| 690 | + | } | |||
| 292 | } | 691 | } | ||
| 293 | 692 | ||||
| 294 | function handleInputScroll(e: Event) { | 693 | function handleInputScroll(e: Event) { | ||
| 295 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | 694 | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | ||
| 296 | } | 695 | } | ||
| 297 | 696 | ||||
| 697 | + | function handleInputFocus() { | |||
| 698 | + | if (!messageInput) return; | |||
| 699 | + | updateMentionState(messageInput); | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | function handleInputClick() { | |||
| 703 | + | if (!messageInput) return; | |||
| 704 | + | updateMentionState(messageInput); | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | function handleInputKeydown(event: KeyboardEvent) { | |||
| 708 | + | if (!messageInput) return; | |||
| 709 | + | if (event.key === 'Escape') { | |||
| 710 | + | event.preventDefault(); | |||
| 711 | + | if (mentionOpen && mentionAnchorIndex !== null) { | |||
| 712 | + | mentionDismissKey = `${mentionAnchorIndex}:${mentionQuery}`; | |||
| 713 | + | } | |||
| 714 | + | closeMentionPicker(); | |||
| 715 | + | return; | |||
| 716 | + | } | |||
| 717 | + | if (mentionOpen) { | |||
| 718 | + | handleMentionKeydown(event); | |||
| 719 | + | return; | |||
| 720 | + | } | |||
| 721 | + | if (event.key === 'Enter') { | |||
| 722 | + | event.preventDefault(); | |||
| 723 | + | void handleSubmit(); | |||
| 724 | + | } | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | function handleInputKeyup() { | |||
| 728 | + | if (!messageInput) return; | |||
| 729 | + | updateMentionState(messageInput); | |||
| 730 | + | } | |||
| 731 | + | ||||
| 298 | async function handleSubmit() { | 732 | async function handleSubmit() { | ||
| 299 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | 733 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | ||
| 300 | 734 | ||||
| @@ -439,6 +873,11 @@ | |||||
| 439 | showAuth = false; | 873 | showAuth = false; | ||
| 440 | } | 874 | } | ||
| 441 | 875 | ||||
| 876 | + | function handleMentionMouseDown(event: MouseEvent, user: SafeUser) { | |||
| 877 | + | event.preventDefault(); | |||
| 878 | + | applyMentionSelection(user); | |||
| 879 | + | } | |||
| 880 | + | ||||
| 442 | // ============ LIFECYCLE ============ | 881 | // ============ LIFECYCLE ============ | ||
| 443 | 882 | ||||
| 444 | onMount(() => { | 883 | onMount(() => { | ||
| @@ -520,6 +959,8 @@ | |||||
| 520 | return () => { | 959 | return () => { | ||
| 521 | window.removeEventListener('resize', handleResize); | 960 | window.removeEventListener('resize', handleResize); | ||
| 522 | if (cooldownInterval) clearInterval(cooldownInterval); | 961 | if (cooldownInterval) clearInterval(cooldownInterval); | ||
| 962 | + | // Clean up canvas context used for text measurement | |||
| 963 | + | mentionMeasureContext = null; | |||
| 523 | }; | 964 | }; | ||
| 524 | }); | 965 | }); | ||
| 525 | 966 | ||||
| @@ -537,6 +978,14 @@ | |||||
| 537 | isCentered | 978 | isCentered | ||
| 538 | }); | 979 | }); | ||
| 539 | }); | 980 | }); | ||
| 981 | + | ||||
| 982 | + | // Scroll selected mention option into view when selection changes | |||
| 983 | + | $effect(() => { | |||
| 984 | + | if (!mentionOpen || mentionSuggestions.length === 0) return; | |||
| 985 | + | // Track mentionSelectedIndex to trigger scroll on keyboard navigation | |||
| 986 | + | const _index = mentionSelectedIndex; | |||
| 987 | + | void tick().then(() => scrollMentionOptionIntoView()); | |||
| 988 | + | }); | |||
| 540 | </script> | 989 | </script> | ||
| 541 | 990 | ||||
| 542 | {#if showChatRoom} | 991 | {#if showChatRoom} | ||
| @@ -567,7 +1016,8 @@ | |||||
| 567 | class="title-bar-text" | 1016 | 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;" | 1017 | style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;" | ||
| 569 | > | 1018 | > | ||
| 570 | - | Pdr Aim {#if currentUser} | 1019 | + | Pdr Aim {#if mentionUnreadCount > 0}({mentionUnreadCount}) |
| 1020 | + | {/if}{#if currentUser} | |||
| 571 | - {currentUser.nickname}{:else} | 1021 | - {currentUser.nickname}{:else} | ||
| 572 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | 1022 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | ||
| 573 | {#if connectionError && !isMinimized} | 1023 | {#if connectionError && !isMinimized} | ||
| @@ -640,6 +1090,7 @@ | |||||
| 640 | class="sunken-panel chat-area" | 1090 | class="sunken-panel chat-area" | ||
| 641 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 1091 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | ||
| 642 | onscroll={(e) => handleScroll(e)} | 1092 | onscroll={(e) => handleScroll(e)} | ||
| 1093 | + | bind:this={chatArea} | |||
| 643 | > | 1094 | > | ||
| 644 | {#if isInitialLoading} | 1095 | {#if isInitialLoading} | ||
| 645 | <div class="initial-loading"> | 1096 | <div class="initial-loading"> | ||
| @@ -659,7 +1110,18 @@ | |||||
| 659 | </div> | 1110 | </div> | ||
| 660 | {/if} | 1111 | {/if} | ||
| 661 | {#each visibleMessages as message (message.id)} | 1112 | {#each visibleMessages as message (message.id)} | ||
| 662 | - | <div class="message {message.type} text"> | 1113 | + | <div |
| 1114 | + | class="message {message.type} text" | |||
| 1115 | + | class:mention-highlight={hasMentionHighlight[message.id]} | |||
| 1116 | + | data-message-id={message.id} | |||
| 1117 | + | data-mention={hasMentionHighlight[message.id] | |||
| 1118 | + | ? `Mention de @${currentUser?.nickname ?? ''}` | |||
| 1119 | + | : undefined} | |||
| 1120 | + | aria-label={hasMentionHighlight[message.id] | |||
| 1121 | + | ? `Message mentionnant ${currentUser?.nickname ?? ''}` | |||
| 1122 | + | : undefined} | |||
| 1123 | + | use:registerMentionNode | |||
| 1124 | + | > | |||
| 663 | {#if message.type === 'emote'} | 1125 | {#if message.type === 'emote'} | ||
| 664 | <span class="emote-text"> | 1126 | <span class="emote-text"> | ||
| 665 | <Tooltip | 1127 | <Tooltip | ||
| @@ -737,7 +1199,7 @@ | |||||
| 737 | -webkit-text-fill-color: transparent; | 1199 | -webkit-text-fill-color: transparent; | ||
| 738 | background-clip: text; | 1200 | background-clip: text; | ||
| 739 | " | 1201 | " | ||
| 740 | - | aria-hidden="true">{currentMessage}</span | 1202 | + | aria-hidden="true">{@html formattedInputMessage}</span |
| 741 | > | 1203 | > | ||
| 742 | <input | 1204 | <input | ||
| 743 | type="text" | 1205 | type="text" | ||
| @@ -753,9 +1215,13 @@ | |||||
| 753 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | 1215 | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | ||
| 754 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | 1216 | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | ||
| 755 | : ''}" | 1217 | : ''}" | ||
| 756 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 1218 | + | onkeydown={handleInputKeydown} |
| 1219 | + | onkeyup={handleInputKeyup} | |||
| 757 | oninput={handleInputChange} | 1220 | oninput={handleInputChange} | ||
| 758 | onscroll={handleInputScroll} | 1221 | onscroll={handleInputScroll} | ||
| 1222 | + | onfocus={handleInputFocus} | |||
| 1223 | + | onclick={handleInputClick} | |||
| 1224 | + | bind:this={messageInput} | |||
| 759 | placeholder={cooldownEndTime | 1225 | placeholder={cooldownEndTime | ||
| 760 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1226 | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | ||
| 761 | : 'Écrivez un message...'} | 1227 | : 'Écrivez un message...'} | ||
| @@ -763,18 +1229,61 @@ | |||||
| 763 | /> | 1229 | /> | ||
| 764 | </div> | 1230 | </div> | ||
| 765 | {:else} | 1231 | {:else} | ||
| 766 | - | <input | 1232 | + | <div |
| 767 | - | type="text" | 1233 | + | class="mention-input-wrapper" |
| 768 | - | bind:value={currentMessage} | 1234 | + | style="flex: 1; position: relative; background: white; overflow: hidden;" |
| 769 | - | maxlength={MAX_MESSAGE_LENGTH} | 1235 | + | > |
| 770 | - | class="styled-input retro-font-{currentTextStyle.fontFamily}" | 1236 | + | <!-- Text overlay with mention highlighting --> |
| 771 | - | style="flex: 1; {generateInputCSSStyle(currentTextStyle)}" | 1237 | + | <span |
| 772 | - | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 1238 | + | class="mention-text-overlay retro-font-{currentTextStyle.fontFamily}" |
| 773 | - | placeholder={cooldownEndTime | 1239 | + | style=" |
| 774 | - | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | 1240 | + | position: absolute; |
| 775 | - | : 'Écrivez un message...'} | 1241 | + | left: 3px; |
| 776 | - | disabled={!currentUser || Boolean(cooldownEndTime)} | 1242 | + | top: 50%; |
| 777 | - | /> | 1243 | + | transform: translateY(-50%) translateX(-{inputScrollLeft}px); |
| 1244 | + | pointer-events: none; | |||
| 1245 | + | white-space: nowrap; | |||
| 1246 | + | font-size: {currentTextStyle.fontSize}px; | |||
| 1247 | + | color: {currentTextStyle.color || '#000000'}; | |||
| 1248 | + | {currentTextStyle.bold | |||
| 1249 | + | ? currentTextStyle.fontFamily === 'tahoma' | |||
| 1250 | + | ? 'font-weight: 200;' | |||
| 1251 | + | : 'font-weight: 700;' | |||
| 1252 | + | : ''} | |||
| 1253 | + | {currentTextStyle.italic ? 'font-style: italic;' : ''} | |||
| 1254 | + | {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 1255 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 1256 | + | : ''} | |||
| 1257 | + | " | |||
| 1258 | + | aria-hidden="true">{@html formattedInputMessage}</span | |||
| 1259 | + | > | |||
| 1260 | + | <input | |||
| 1261 | + | type="text" | |||
| 1262 | + | bind:value={currentMessage} | |||
| 1263 | + | maxlength={MAX_MESSAGE_LENGTH} | |||
| 1264 | + | class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}" | |||
| 1265 | + | style="width: 100%; background: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold | |||
| 1266 | + | ? currentTextStyle.fontFamily === 'tahoma' | |||
| 1267 | + | ? 'font-weight: 200;' | |||
| 1268 | + | : 'font-weight: 700;' | |||
| 1269 | + | : ''} {currentTextStyle.italic | |||
| 1270 | + | ? 'font-style: italic;' | |||
| 1271 | + | : ''} {currentTextStyle.underline || currentTextStyle.strikethrough | |||
| 1272 | + | ? `text-decoration: ${[currentTextStyle.underline ? 'underline' : '', currentTextStyle.strikethrough ? 'line-through' : ''].filter(Boolean).join(' ')};` | |||
| 1273 | + | : ''} caret-color: {currentTextStyle.color || 'black'};" | |||
| 1274 | + | onkeydown={handleInputKeydown} | |||
| 1275 | + | onkeyup={handleInputKeyup} | |||
| 1276 | + | oninput={handleInputChange} | |||
| 1277 | + | onscroll={handleInputScroll} | |||
| 1278 | + | onfocus={handleInputFocus} | |||
| 1279 | + | onclick={handleInputClick} | |||
| 1280 | + | bind:this={messageInput} | |||
| 1281 | + | placeholder={cooldownEndTime | |||
| 1282 | + | ? `Patientez ${cooldownProgress.toFixed(1)}s...` | |||
| 1283 | + | : 'Écrivez un message...'} | |||
| 1284 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |||
| 1285 | + | /> | |||
| 1286 | + | </div> | |||
| 778 | {/if} | 1287 | {/if} | ||
| 779 | <LoadingButton | 1288 | <LoadingButton | ||
| 780 | onclick={handleSubmit} | 1289 | onclick={handleSubmit} | ||
| @@ -788,6 +1297,48 @@ | |||||
| 788 | </span> | 1297 | </span> | ||
| 789 | {/if} | 1298 | {/if} | ||
| 790 | </div> | 1299 | </div> | ||
| 1300 | + | {#if mentionOpen} | |||
| 1301 | + | <div | |||
| 1302 | + | class="sunken-panel mention-picker" | |||
| 1303 | + | class:mention-picker-portal={!mentionListInside} | |||
| 1304 | + | role="listbox" | |||
| 1305 | + | aria-label="Suggestions de mentions" | |||
| 1306 | + | aria-activedescendant={mentionActiveId} | |||
| 1307 | + | tabindex="-1" | |||
| 1308 | + | style={`z-index: ${mentionListInside ? 10 : 3000}; ${mentionListStyle}`} | |||
| 1309 | + | onmousedown={(event) => event.preventDefault()} | |||
| 1310 | + | bind:this={mentionListContainer} | |||
| 1311 | + | aria-live="polite" | |||
| 1312 | + | > | |||
| 1313 | + | {#if mentionSuggestions.length > 0} | |||
| 1314 | + | {#each mentionSuggestions as user, index (user.id)} | |||
| 1315 | + | <div | |||
| 1316 | + | id={getMentionListItemId(user.id)} | |||
| 1317 | + | role="option" | |||
| 1318 | + | class="mention-option" | |||
| 1319 | + | class:selected={index === mentionSelectedIndex} | |||
| 1320 | + | aria-selected={index === mentionSelectedIndex} | |||
| 1321 | + | tabindex="-1" | |||
| 1322 | + | onmousedown={(event) => handleMentionMouseDown(event, user)} | |||
| 1323 | + | > | |||
| 1324 | + | <span class="mention-name">{user.nickname}</span> | |||
| 1325 | + | <span class="mention-status {user.status}">{user.status}</span> | |||
| 1326 | + | </div> | |||
| 1327 | + | {/each} | |||
| 1328 | + | {:else} | |||
| 1329 | + | <div | |||
| 1330 | + | class="mention-empty" | |||
| 1331 | + | role="option" | |||
| 1332 | + | aria-disabled="true" | |||
| 1333 | + | aria-selected="false" | |||
| 1334 | + | tabindex="-1" | |||
| 1335 | + | > | |||
| 1336 | + | Aucun utilisateur | |||
| 1337 | + | </div> | |||
| 1338 | + | {/if} | |||
| 1339 | + | </div> | |||
| 1340 | + | {/if} | |||
| 1341 | + | ||||
| 791 | {#if cooldownEndTime} | 1342 | {#if cooldownEndTime} | ||
| 792 | <div | 1343 | <div | ||
| 793 | class="cooldown-progress" | 1344 | class="cooldown-progress" | ||
| @@ -936,6 +1487,42 @@ | |||||
| 936 | color: #666; | 1487 | color: #666; | ||
| 937 | } | 1488 | } | ||
| 938 | 1489 | ||||
| 1490 | + | .message-content :global(.mention-token) { | |||
| 1491 | + | color: #1e2a72; | |||
| 1492 | + | font-weight: bold; | |||
| 1493 | + | } | |||
| 1494 | + | ||||
| 1495 | + | /* Input mention highlighting */ | |||
| 1496 | + | .mention-input-wrapper, | |||
| 1497 | + | .gradient-input-wrapper { | |||
| 1498 | + | flex: 1; | |||
| 1499 | + | position: relative; | |||
| 1500 | + | background: white; | |||
| 1501 | + | overflow: hidden; | |||
| 1502 | + | } | |||
| 1503 | + | ||||
| 1504 | + | .mention-text-overlay, | |||
| 1505 | + | .gradient-text-overlay { | |||
| 1506 | + | position: absolute; | |||
| 1507 | + | left: 3px; | |||
| 1508 | + | top: 50%; | |||
| 1509 | + | pointer-events: none; | |||
| 1510 | + | white-space: nowrap; | |||
| 1511 | + | } | |||
| 1512 | + | ||||
| 1513 | + | .mention-text-overlay :global(.input-mention), | |||
| 1514 | + | .gradient-text-overlay :global(.input-mention) { | |||
| 1515 | + | color: #1e2a72; | |||
| 1516 | + | font-weight: bold; | |||
| 1517 | + | -webkit-text-fill-color: #1e2a72; | |||
| 1518 | + | } | |||
| 1519 | + | ||||
| 1520 | + | /* Force input text to be transparent when using overlay */ | |||
| 1521 | + | .mention-overlay-input { | |||
| 1522 | + | color: transparent !important; | |||
| 1523 | + | -webkit-text-fill-color: transparent !important; | |||
| 1524 | + | } | |||
| 1525 | + | ||||
| 939 | .section-header { | 1526 | .section-header { | ||
| 940 | margin: 0 0 0.25rem 0; | 1527 | margin: 0 0 0.25rem 0; | ||
| 941 | font-weight: bold; | 1528 | font-weight: bold; | ||
| @@ -1000,8 +1587,7 @@ | |||||
| 1000 | z-index: 2; | 1587 | z-index: 2; | ||
| 1001 | } | 1588 | } | ||
| 1002 | 1589 | ||||
| 1003 | - | .input-container input:disabled, | 1590 | + | .input-container input:disabled { |
| 1004 | - | .input-container { | |||
| 1005 | opacity: 0.7; | 1591 | opacity: 0.7; | ||
| 1006 | background-color: rgba(128, 128, 128, 0.1); | 1592 | background-color: rgba(128, 128, 128, 0.1); | ||
| 1007 | cursor: not-allowed; | 1593 | cursor: not-allowed; | ||
| @@ -1160,10 +1746,6 @@ | |||||
| 1160 | font-size: 0.875rem; | 1746 | font-size: 0.875rem; | ||
| 1161 | } | 1747 | } | ||
| 1162 | 1748 | ||||
| 1163 | - | .error-banner small { | |||
| 1164 | - | color: #666; | |||
| 1165 | - | } | |||
| 1166 | - | ||||
| 1167 | .rate-limit-warning { | 1749 | .rate-limit-warning { | ||
| 1168 | background: #fff3e0; | 1750 | background: #fff3e0; | ||
| 1169 | color: #e65100; | 1751 | color: #e65100; | ||
| @@ -1198,6 +1780,77 @@ | |||||
| 1198 | } | 1780 | } | ||
| 1199 | } | 1781 | } | ||
| 1200 | 1782 | ||||
| 1783 | + | .mention-picker { | |||
| 1784 | + | position: absolute; | |||
| 1785 | + | padding: 0.35rem; | |||
| 1786 | + | max-height: 220px; | |||
| 1787 | + | overflow-y: auto; | |||
| 1788 | + | background: #fffef5; | |||
| 1789 | + | border: 2px inset #dfdfdf; | |||
| 1790 | + | box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15); | |||
| 1791 | + | font-family: 'Pixelated MS Sans Serif', Tahoma, sans-serif; | |||
| 1792 | + | font-size: 0.85rem; | |||
| 1793 | + | z-index: 10; | |||
| 1794 | + | min-width: 220px; | |||
| 1795 | + | } | |||
| 1796 | + | ||||
| 1797 | + | .mention-picker-portal { | |||
| 1798 | + | position: fixed; | |||
| 1799 | + | } | |||
| 1800 | + | ||||
| 1801 | + | .mention-option { | |||
| 1802 | + | display: flex; | |||
| 1803 | + | align-items: center; | |||
| 1804 | + | justify-content: space-between; | |||
| 1805 | + | gap: 0.5rem; | |||
| 1806 | + | padding: 0.25rem 0.4rem; | |||
| 1807 | + | border: 1px solid transparent; | |||
| 1808 | + | cursor: pointer; | |||
| 1809 | + | } | |||
| 1810 | + | ||||
| 1811 | + | .mention-option.selected { | |||
| 1812 | + | background: #dbe8ff; | |||
| 1813 | + | border-color: #2d31a6; | |||
| 1814 | + | } | |||
| 1815 | + | ||||
| 1816 | + | .mention-name { | |||
| 1817 | + | font-weight: bold; | |||
| 1818 | + | color: #1e2a72; | |||
| 1819 | + | } | |||
| 1820 | + | ||||
| 1821 | + | .mention-status { | |||
| 1822 | + | font-size: 0.7rem; | |||
| 1823 | + | text-transform: uppercase; | |||
| 1824 | + | letter-spacing: 0.05em; | |||
| 1825 | + | color: #5b5b5b; | |||
| 1826 | + | } | |||
| 1827 | + | ||||
| 1828 | + | .mention-status.online { | |||
| 1829 | + | color: #1f7a1f; | |||
| 1830 | + | } | |||
| 1831 | + | ||||
| 1832 | + | .mention-status.away, | |||
| 1833 | + | .mention-status.busy { | |||
| 1834 | + | color: #b06a00; | |||
| 1835 | + | } | |||
| 1836 | + | ||||
| 1837 | + | .mention-status.offline { | |||
| 1838 | + | color: #8a8a8a; | |||
| 1839 | + | } | |||
| 1840 | + | ||||
| 1841 | + | .mention-empty { | |||
| 1842 | + | padding: 0.35rem 0.5rem; | |||
| 1843 | + | color: #777; | |||
| 1844 | + | font-style: italic; | |||
| 1845 | + | } | |||
| 1846 | + | ||||
| 1847 | + | .message.mention-highlight { | |||
| 1848 | + | background: rgba(255, 237, 186, 0.45); | |||
| 1849 | + | border-radius: 4px; | |||
| 1850 | + | padding: 0.2rem 0.35rem; | |||
| 1851 | + | box-shadow: inset 0 0 0 1px rgba(178, 136, 0, 0.2); | |||
| 1852 | + | } | |||
| 1853 | + | ||||
| 1201 | .cooldown-progress { | 1854 | .cooldown-progress { | ||
| 1202 | position: absolute; | 1855 | position: absolute; | ||
| 1203 | bottom: 0; | 1856 | bottom: 0; | ||
M src/lib/components/formatted-message.svelte +16 -1
| @@ -15,10 +15,19 @@ | |||
| 15 | 15 | let isProcessing = $state(false); | |
| 16 | 16 | let hasError = $state(false); | |
| 17 | 17 | ||
| 18 | + | // Apply mention highlighting to text | |
| 19 | + | function applyMentionHighlighting(text: string): string { | |
| 20 | + | return text.replace( | |
| 21 | + | /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g, | |
| 22 | + | '$1<span class="mention-token">@$2</span>' | |
| 23 | + | ); | |
| 24 | + | } | |
| 25 | + | ||
| 18 | 26 | // Process message formatting | |
| 19 | 27 | async function processMessage() { | |
| 20 | 28 | if (!allowFormatting || !message.hasFormatting) { | |
| 21 | - | formattedContent = escapeHtml(message.content); | |
| 29 | + | // Still apply mention highlighting to plain text messages | |
| 30 | + | formattedContent = applyMentionHighlighting(escapeHtml(message.content)); | |
| 22 | 31 | return; | |
| 23 | 32 | } | |
| 24 | 33 | ||
| @@ -234,6 +243,12 @@ | |||
| 234 | 243 | color: inherit; | |
| 235 | 244 | } | |
| 236 | 245 | ||
| 246 | + | /* Mention token styling */ | |
| 247 | + | :global(.mention-token) { | |
| 248 | + | color: #1e2a72; | |
| 249 | + | font-weight: bold; | |
| 250 | + | } | |
| 251 | + | ||
| 237 | 252 | /* Ensure accessibility for screen readers */ | |
| 238 | 253 | :global(.formatted-message[aria-label]) { | |
| 239 | 254 | position: relative; | |
| @@ -15,10 +15,19 @@ | |||||
| 15 | let isProcessing = $state(false); | 15 | let isProcessing = $state(false); | ||
| 16 | let hasError = $state(false); | 16 | let hasError = $state(false); | ||
| 17 | 17 | ||||
| 18 | + | // Apply mention highlighting to text | |||
| 19 | + | function applyMentionHighlighting(text: string): string { | |||
| 20 | + | return text.replace( | |||
| 21 | + | /(^|\s)@([a-zA-Z0-9_-]{1,32})(?=$|\s|[^a-zA-Z0-9_-])/g, | |||
| 22 | + | '$1<span class="mention-token">@$2</span>' | |||
| 23 | + | ); | |||
| 24 | + | } | |||
| 25 | + | ||||
| 18 | // Process message formatting | 26 | // Process message formatting | ||
| 19 | async function processMessage() { | 27 | async function processMessage() { | ||
| 20 | if (!allowFormatting || !message.hasFormatting) { | 28 | if (!allowFormatting || !message.hasFormatting) { | ||
| 21 | - | formattedContent = escapeHtml(message.content); | 29 | + | // Still apply mention highlighting to plain text messages |
| 30 | + | formattedContent = applyMentionHighlighting(escapeHtml(message.content)); | |||
| 22 | return; | 31 | return; | ||
| 23 | } | 32 | } | ||
| 24 | 33 | ||||
| @@ -234,6 +243,12 @@ | |||||
| 234 | color: inherit; | 243 | color: inherit; | ||
| 235 | } | 244 | } | ||
| 236 | 245 | ||||
| 246 | + | /* Mention token styling */ | |||
| 247 | + | :global(.mention-token) { | |||
| 248 | + | color: #1e2a72; | |||
| 249 | + | font-weight: bold; | |||
| 250 | + | } | |||
| 251 | + | ||||
| 237 | /* Ensure accessibility for screen readers */ | 252 | /* Ensure accessibility for screen readers */ | ||
| 238 | :global(.formatted-message[aria-label]) { | 253 | :global(.formatted-message[aria-label]) { | ||
| 239 | position: relative; | 254 | position: relative; | ||
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 Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0); | |
| 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 Object.values(this._roomMentionCounts).reduce((sum, count) => sum + count, 0); | |||
| 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; | ||
M src/lib/utils/text-formatter.ts +9 -0
| @@ -125,6 +125,9 @@ function preprocessFormatting(text: string): string { | |||
| 125 | 125 | }); | |
| 126 | 126 | }); | |
| 127 | 127 | ||
| 128 | + | // Note: Mention highlighting is applied after sanitization in formatText | |
| 129 | + | // to ensure it's not stripped by the sanitizer | |
| 130 | + | ||
| 128 | 131 | return processedText; | |
| 129 | 132 | } | |
| 130 | 133 | ||
| @@ -185,6 +188,12 @@ export async function formatText( | |||
| 185 | 188 | return escapeHtml(text); | |
| 186 | 189 | } | |
| 187 | 190 | ||
| 191 | + | // Apply mention highlighting after sanitization | |
| 192 | + | formattedHtml = formattedHtml.replace( | |
| 193 | + | /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g, | |
| 194 | + | '$1<span class="mention-token">@$2</span>' | |
| 195 | + | ); | |
| 196 | + | ||
| 188 | 197 | return formattedHtml; | |
| 189 | 198 | } catch (error) { | |
| 190 | 199 | console.error('Text formatting error:', error); | |
| @@ -125,6 +125,9 @@ function preprocessFormatting(text: string): string { | |||||
| 125 | }); | 125 | }); | ||
| 126 | }); | 126 | }); | ||
| 127 | 127 | ||||
| 128 | + | // Note: Mention highlighting is applied after sanitization in formatText | |||
| 129 | + | // to ensure it's not stripped by the sanitizer | |||
| 130 | + | ||||
| 128 | return processedText; | 131 | return processedText; | ||
| 129 | } | 132 | } | ||
| 130 | 133 | ||||
| @@ -185,6 +188,12 @@ export async function formatText( | |||||
| 185 | return escapeHtml(text); | 188 | return escapeHtml(text); | ||
| 186 | } | 189 | } | ||
| 187 | 190 | ||||
| 191 | + | // Apply mention highlighting after sanitization | |||
| 192 | + | formattedHtml = formattedHtml.replace( | |||
| 193 | + | /(^|[\s>])@([a-zA-Z0-9_-]{1,32})(?=$|[\s<]|[^a-zA-Z0-9_-])/g, | |||
| 194 | + | '$1<span class="mention-token">@$2</span>' | |||
| 195 | + | ); | |||
| 196 | + | ||||
| 188 | return formattedHtml; | 197 | return formattedHtml; | ||
| 189 | } catch (error) { | 198 | } catch (error) { | ||
| 190 | console.error('Text formatting error:', error); | 199 | console.error('Text formatting error:', error); | ||