@trustquery/browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2904 @@
1
+ // OverlayRenderer - Renders text with styled matches in a line-based overlay
2
+ // Much simpler than grid approach - each line is a div, matches are spans
3
+
4
+ class OverlayRenderer {
5
+ /**
6
+ * Create a renderer
7
+ * @param {HTMLElement} overlay - Overlay container element
8
+ * @param {Object} options - Rendering options
9
+ */
10
+ constructor(overlay, options = {}) {
11
+ this.overlay = overlay;
12
+ this.options = {
13
+ theme: options.theme || 'light',
14
+ commandHandlers: options.commandHandlers || null,
15
+ ...options
16
+ };
17
+
18
+ console.log('[OverlayRenderer] Initialized');
19
+ }
20
+
21
+ /**
22
+ * Render text with matches
23
+ * @param {string} text - Text content
24
+ * @param {Array} matches - Array of match objects from CommandScanner
25
+ */
26
+ render(text, matches = []) {
27
+ // Split text into lines
28
+ const lines = text.split('\n');
29
+
30
+ // Build HTML for each line
31
+ const linesHTML = lines.map((line, lineIndex) => {
32
+ return this.renderLine(line, lineIndex, matches);
33
+ }).join('');
34
+
35
+ // Update overlay
36
+ this.overlay.innerHTML = linesHTML;
37
+
38
+ console.log('[OverlayRenderer] Rendered', lines.length, 'lines with', matches.length, 'matches');
39
+ }
40
+
41
+ /**
42
+ * Render a single line with matches
43
+ * @param {string} line - Line text
44
+ * @param {number} lineIndex - Line number (0-indexed)
45
+ * @param {Array} matches - All matches from scanner
46
+ * @returns {string} HTML for line
47
+ */
48
+ renderLine(line, lineIndex, matches) {
49
+ // Find matches on this line
50
+ const lineMatches = this.getMatchesForLine(line, lineIndex, matches);
51
+
52
+ if (lineMatches.length === 0) {
53
+ // No matches - render plain line
54
+ return `<div class="tq-line">${this.escapeHtml(line) || '&nbsp;'}</div>`;
55
+ }
56
+
57
+ // Sort matches by start position
58
+ lineMatches.sort((a, b) => a.start - b.start);
59
+
60
+ // Build HTML with matches as spans
61
+ let html = '<div class="tq-line">';
62
+ let lastIndex = 0;
63
+
64
+ for (const match of lineMatches) {
65
+ // Add text before match
66
+ if (match.start > lastIndex) {
67
+ html += this.escapeHtml(line.substring(lastIndex, match.start));
68
+ }
69
+
70
+ // Add match as styled span
71
+ const matchText = line.substring(match.start, match.end);
72
+ const classes = this.getMatchClasses(match);
73
+ const dataAttrs = this.getMatchDataAttributes(match);
74
+ const inlineStyles = this.getMatchInlineStyles(match);
75
+
76
+ html += `<span class="${classes}" ${dataAttrs} style="${inlineStyles}">${this.escapeHtml(matchText)}</span>`;
77
+
78
+ lastIndex = match.end;
79
+ }
80
+
81
+ // Add remaining text after last match
82
+ if (lastIndex < line.length) {
83
+ html += this.escapeHtml(line.substring(lastIndex));
84
+ }
85
+
86
+ // Handle empty lines
87
+ if (line.length === 0) {
88
+ html += '&nbsp;';
89
+ }
90
+
91
+ html += '</div>';
92
+
93
+ return html;
94
+ }
95
+
96
+ /**
97
+ * Get matches that apply to a specific line
98
+ * @param {string} line - Line text
99
+ * @param {number} lineIndex - Line number
100
+ * @param {Array} matches - All matches
101
+ * @returns {Array} Matches for this line with adjusted positions
102
+ */
103
+ getMatchesForLine(line, lineIndex, matches) {
104
+ const lineMatches = [];
105
+
106
+ // Calculate absolute position of this line in the full text
107
+ // (we'll need to know line starts to filter matches)
108
+ // For now, matches already have line info from scanner
109
+
110
+ for (const match of matches) {
111
+ if (match.line === lineIndex) {
112
+ lineMatches.push({
113
+ ...match,
114
+ start: match.col, // Column position on this line
115
+ end: match.col + match.length
116
+ });
117
+ }
118
+ }
119
+
120
+ return lineMatches;
121
+ }
122
+
123
+ /**
124
+ * Get CSS classes for a match
125
+ * @param {Object} match - Match object
126
+ * @returns {string} Space-separated class names
127
+ */
128
+ getMatchClasses(match) {
129
+ const classes = ['tq-match'];
130
+
131
+ // Add command type as class
132
+ if (match.command && match.command.type) {
133
+ classes.push(`tq-match-${match.command.type}`);
134
+ }
135
+
136
+ // Add behavior as class (bubble, dropdown, etc.)
137
+ if (match.command && match.command.behavior) {
138
+ classes.push(`tq-behavior-${match.command.behavior}`);
139
+ }
140
+
141
+ return classes.join(' ');
142
+ }
143
+
144
+ /**
145
+ * Get data attributes for a match
146
+ * @param {Object} match - Match object
147
+ * @returns {string} Data attributes string
148
+ */
149
+ getMatchDataAttributes(match) {
150
+ const attrs = [];
151
+
152
+ // Store match text
153
+ attrs.push(`data-match-text="${this.escapeAttr(match.text)}"`);
154
+
155
+ // Store command info
156
+ if (match.command) {
157
+ attrs.push(`data-command-id="${this.escapeAttr(match.command.id || '')}"`);
158
+ attrs.push(`data-command-type="${this.escapeAttr(match.command.commandType || '')}"`);
159
+ attrs.push(`data-command-path="${this.escapeAttr(match.command.commandPath || '')}"`);
160
+ attrs.push(`data-intent-path="${this.escapeAttr(match.command.intentPath || '')}"`);
161
+
162
+ // Store intent info as JSON for InteractionHandler
163
+ if (match.command.intent) {
164
+ attrs.push(`data-intent='${this.escapeAttr(JSON.stringify(match.command.intent))}'`);
165
+ }
166
+
167
+ // Determine behavior based on handler properties
168
+ if (match.command.intent && match.command.intent.handler) {
169
+ const handler = match.command.intent.handler;
170
+ if (handler.options && Array.isArray(handler.options) && handler.options.length > 0) {
171
+ attrs.push(`data-behavior="dropdown"`);
172
+ } else if (handler.message || handler['message-content'] || match.command.intent.description) {
173
+ attrs.push(`data-behavior="bubble"`);
174
+ }
175
+ }
176
+ }
177
+
178
+ // Store position info
179
+ attrs.push(`data-line="${match.line}"`);
180
+ attrs.push(`data-col="${match.col}"`);
181
+
182
+ return attrs.join(' ');
183
+ }
184
+
185
+ /**
186
+ * Get inline styles for a match based on command type or message-state
187
+ * @param {Object} match - Match object
188
+ * @returns {string} Inline style string
189
+ */
190
+ getMatchInlineStyles(match) {
191
+ if (!this.options.commandHandlers || !match.command) {
192
+ return 'pointer-events: auto; cursor: pointer;';
193
+ }
194
+
195
+ const commandType = match.command.commandType;
196
+
197
+ // Create matchData with intent info for message-state lookup
198
+ const matchData = {
199
+ intent: match.command.intent,
200
+ commandType: commandType
201
+ };
202
+
203
+ const styles = this.options.commandHandlers.getStyles(commandType, matchData);
204
+
205
+ // Convert style object to CSS string
206
+ const styleStr = Object.entries(styles)
207
+ .map(([key, value]) => {
208
+ // Convert camelCase to kebab-case
209
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
210
+ return `${cssKey}: ${value}`;
211
+ })
212
+ .join('; ');
213
+
214
+ return styleStr + '; pointer-events: auto;';
215
+ }
216
+
217
+ /**
218
+ * Escape HTML to prevent XSS
219
+ * @param {string} text - Text to escape
220
+ * @returns {string} Escaped text
221
+ */
222
+ escapeHtml(text) {
223
+ const div = document.createElement('div');
224
+ div.textContent = text;
225
+ return div.innerHTML;
226
+ }
227
+
228
+ /**
229
+ * Escape attribute value
230
+ * @param {string} text - Text to escape
231
+ * @returns {string} Escaped text
232
+ */
233
+ escapeAttr(text) {
234
+ return String(text)
235
+ .replace(/&/g, '&amp;')
236
+ .replace(/"/g, '&quot;')
237
+ .replace(/'/g, '&#39;')
238
+ .replace(/</g, '&lt;')
239
+ .replace(/>/g, '&gt;');
240
+ }
241
+ }
242
+
243
+ // CommandScanner - Scans text for word matches based on command map
244
+ // Uses simple string matching (not regex) for simplicity and performance
245
+
246
+ class CommandScanner {
247
+ constructor() {
248
+ this.commandMap = null;
249
+ this.commands = [];
250
+ console.log('[CommandScanner] Initialized');
251
+ }
252
+
253
+ /**
254
+ * Set command map
255
+ * @param {Object} commandMap - Command map object
256
+ */
257
+ setCommandMap(commandMap) {
258
+ this.commandMap = commandMap;
259
+ this.commands = this.parseCommandMap(commandMap);
260
+ console.log('[CommandScanner] Command map set with', this.commands.length, 'commands');
261
+ }
262
+
263
+ /**
264
+ * Parse simplified TQL triggers format
265
+ * Format: { "tql-triggers": { "error": [...], "warning": [...], "info": [...] } }
266
+ * @param {Object} triggerMap - Trigger map from tql-triggers.json
267
+ * @returns {Array} Array of command objects
268
+ */
269
+ parseCommandMap(triggerMap) {
270
+ const commands = [];
271
+
272
+ // Extract tql-triggers
273
+ const triggers = triggerMap['tql-triggers'] || triggerMap;
274
+
275
+ if (!triggers || typeof triggers !== 'object') {
276
+ console.warn('[CommandScanner] Invalid trigger map structure');
277
+ return commands;
278
+ }
279
+
280
+ // Iterate through each message state (error, warning, info)
281
+ Object.keys(triggers).forEach(messageState => {
282
+ // Skip metadata fields
283
+ if (messageState.startsWith('$')) {
284
+ return;
285
+ }
286
+
287
+ const triggerList = triggers[messageState];
288
+
289
+ if (!Array.isArray(triggerList)) {
290
+ return;
291
+ }
292
+
293
+ // Process each trigger in this state
294
+ triggerList.forEach((trigger, index) => {
295
+ const handler = trigger.handler || {};
296
+ const description = trigger.description || handler.message || '';
297
+ const category = trigger.category || 'general';
298
+
299
+ // Create intent object compatible with handlers
300
+ const intent = {
301
+ description: description,
302
+ handler: {
303
+ ...handler,
304
+ 'message-state': messageState // Add message-state for styling
305
+ },
306
+ category: category
307
+ };
308
+
309
+ // Handle regex patterns
310
+ if (trigger.type === 'regex' && trigger.regex) {
311
+ trigger.regex.forEach(pattern => {
312
+ commands.push({
313
+ id: `${messageState}-${category}-${index}`,
314
+ match: pattern,
315
+ matchType: 'regex',
316
+ messageState: messageState,
317
+ category: category,
318
+ intent: intent,
319
+ handler: intent.handler,
320
+ caseSensitive: true,
321
+ wholeWord: false
322
+ });
323
+ });
324
+ }
325
+
326
+ // Handle string matches
327
+ if (trigger.type === 'match' && trigger.match) {
328
+ trigger.match.forEach(matchStr => {
329
+ commands.push({
330
+ id: `${messageState}-${category}-${index}`,
331
+ match: matchStr,
332
+ matchType: 'string',
333
+ messageState: messageState,
334
+ category: category,
335
+ intent: intent,
336
+ handler: intent.handler,
337
+ caseSensitive: false, // Case insensitive for natural language
338
+ wholeWord: true // Whole word matching
339
+ });
340
+ });
341
+ }
342
+ });
343
+ });
344
+
345
+ // Sort by length (longest first) to match longer patterns first
346
+ commands.sort((a, b) => b.match.length - a.match.length);
347
+
348
+ console.log('[CommandScanner] Parsed commands:', commands.length, commands);
349
+
350
+ return commands;
351
+ }
352
+
353
+ /**
354
+ * Scan text for all matches
355
+ * @param {string} text - Text to scan
356
+ * @returns {Array} Array of match objects with position info
357
+ */
358
+ scan(text) {
359
+ if (!this.commands || this.commands.length === 0) {
360
+ return [];
361
+ }
362
+
363
+ const matches = [];
364
+ const lines = text.split('\n');
365
+
366
+ // Scan each line
367
+ lines.forEach((line, lineIndex) => {
368
+ const lineMatches = this.scanLine(line, lineIndex);
369
+ matches.push(...lineMatches);
370
+ });
371
+
372
+ console.log('[CommandScanner] Found', matches.length, 'matches');
373
+ return matches;
374
+ }
375
+
376
+ /**
377
+ * Scan a single line for matches
378
+ * @param {string} line - Line text
379
+ * @param {number} lineIndex - Line number (0-indexed)
380
+ * @returns {Array} Matches on this line
381
+ */
382
+ scanLine(line, lineIndex) {
383
+ const matches = [];
384
+ const matchedRanges = []; // Track matched positions to avoid overlaps
385
+
386
+ for (const command of this.commands) {
387
+ const commandMatches = this.findMatches(line, command, lineIndex);
388
+
389
+ // Filter out overlapping matches
390
+ for (const match of commandMatches) {
391
+ if (!this.overlapsExisting(match, matchedRanges)) {
392
+ matches.push(match);
393
+ matchedRanges.push({ start: match.col, end: match.col + match.length });
394
+ }
395
+ }
396
+ }
397
+
398
+ // Sort matches by column position
399
+ matches.sort((a, b) => a.col - b.col);
400
+
401
+ return matches;
402
+ }
403
+
404
+ /**
405
+ * Find all matches of a command in a line
406
+ * @param {string} line - Line text
407
+ * @param {Object} command - Command to search for
408
+ * @param {number} lineIndex - Line number
409
+ * @returns {Array} Matches
410
+ */
411
+ findMatches(line, command, lineIndex) {
412
+ const matches = [];
413
+
414
+ // Handle regex patterns
415
+ if (command.matchType === 'regex') {
416
+ try {
417
+ const regex = new RegExp(command.match, 'g');
418
+ let match;
419
+
420
+ while ((match = regex.exec(line)) !== null) {
421
+ matches.push({
422
+ text: match[0],
423
+ line: lineIndex,
424
+ col: match.index,
425
+ length: match[0].length,
426
+ command: command
427
+ });
428
+ }
429
+ } catch (e) {
430
+ console.warn('[CommandScanner] Invalid regex pattern:', command.match, e);
431
+ }
432
+
433
+ return matches;
434
+ }
435
+
436
+ // Handle string patterns (original logic)
437
+ const searchText = command.caseSensitive ? line : line.toLowerCase();
438
+ const pattern = command.caseSensitive ? command.match : command.match.toLowerCase();
439
+
440
+ let startIndex = 0;
441
+
442
+ while (true) {
443
+ const index = searchText.indexOf(pattern, startIndex);
444
+
445
+ if (index === -1) {
446
+ break; // No more matches
447
+ }
448
+
449
+ // Check if this is a whole word match (if required)
450
+ if (command.wholeWord && !this.isWholeWordMatch(line, index, pattern.length)) {
451
+ startIndex = index + 1;
452
+ continue;
453
+ }
454
+
455
+ // Create match object
456
+ matches.push({
457
+ text: line.substring(index, index + pattern.length),
458
+ line: lineIndex,
459
+ col: index,
460
+ length: pattern.length,
461
+ command: command
462
+ });
463
+
464
+ startIndex = index + pattern.length;
465
+ }
466
+
467
+ return matches;
468
+ }
469
+
470
+ /**
471
+ * Check if match is a whole word (not part of a larger word)
472
+ * @param {string} text - Text to check
473
+ * @param {number} start - Start index of match
474
+ * @param {number} length - Length of match
475
+ * @returns {boolean} True if whole word
476
+ */
477
+ isWholeWordMatch(text, start, length) {
478
+ const end = start + length;
479
+
480
+ // Check character before
481
+ if (start > 0) {
482
+ const before = text[start - 1];
483
+ if (this.isWordChar(before)) {
484
+ return false;
485
+ }
486
+ }
487
+
488
+ // Check character after - treat "/" as a word boundary (resolved trigger)
489
+ if (end < text.length) {
490
+ const after = text[end];
491
+ if (this.isWordChar(after) || after === '/') {
492
+ return false;
493
+ }
494
+ }
495
+
496
+ return true;
497
+ }
498
+
499
+ /**
500
+ * Check if character is a word character (alphanumeric or underscore)
501
+ * @param {string} char - Character to check
502
+ * @returns {boolean} True if word character
503
+ */
504
+ isWordChar(char) {
505
+ return /[a-zA-Z0-9_]/.test(char);
506
+ }
507
+
508
+ /**
509
+ * Check if a match overlaps with existing matches
510
+ * @param {Object} match - New match to check
511
+ * @param {Array} existingRanges - Array of {start, end} ranges
512
+ * @returns {boolean} True if overlaps
513
+ */
514
+ overlapsExisting(match, existingRanges) {
515
+ const matchStart = match.col;
516
+ const matchEnd = match.col + match.length;
517
+
518
+ for (const range of existingRanges) {
519
+ // Check for overlap
520
+ if (matchStart < range.end && matchEnd > range.start) {
521
+ return true;
522
+ }
523
+ }
524
+
525
+ return false;
526
+ }
527
+ }
528
+
529
+ // BubbleManager - Handles hover bubble tooltips for matched words
530
+
531
+ class BubbleManager {
532
+ /**
533
+ * Create bubble manager
534
+ * @param {Object} options - Configuration
535
+ */
536
+ constructor(options = {}) {
537
+ this.options = {
538
+ bubbleDelay: options.bubbleDelay || 200,
539
+ styleManager: options.styleManager || null,
540
+ commandHandlers: options.commandHandlers || null,
541
+ ...options
542
+ };
543
+
544
+ this.currentBubble = null;
545
+ this.hoverTimeout = null;
546
+
547
+ console.log('[BubbleManager] Initialized');
548
+ }
549
+
550
+ /**
551
+ * Show bubble for a match element
552
+ * @param {HTMLElement} matchEl - Match element
553
+ * @param {Object} matchData - Match data
554
+ */
555
+ showBubble(matchEl, matchData) {
556
+ // Remove any existing bubble
557
+ this.hideBubble();
558
+
559
+ // Get bubble content
560
+ const content = this.getBubbleContent(matchData);
561
+
562
+ if (!content) {
563
+ return; // No content to show
564
+ }
565
+
566
+ // Create bubble
567
+ const bubble = document.createElement('div');
568
+ bubble.className = 'tq-bubble';
569
+
570
+ // Add header container based on message-state
571
+ const messageState = matchData.intent?.handler?.['message-state'] || 'info';
572
+ this.createBubbleHeader(bubble, messageState);
573
+
574
+ // Add content container
575
+ const contentContainer = document.createElement('div');
576
+ contentContainer.className = 'tq-bubble-content';
577
+ contentContainer.innerHTML = content;
578
+ bubble.appendChild(contentContainer);
579
+
580
+ // Apply inline styles via StyleManager
581
+ if (this.options.styleManager) {
582
+ this.options.styleManager.applyBubbleStyles(bubble);
583
+ }
584
+
585
+ // Add to document
586
+ document.body.appendChild(bubble);
587
+ this.currentBubble = bubble;
588
+
589
+ // Position bubble relative to match element
590
+ this.positionBubble(bubble, matchEl);
591
+
592
+ // Auto-hide when mouse leaves bubble
593
+ bubble.addEventListener('mouseleave', () => {
594
+ this.hideBubble();
595
+ });
596
+
597
+ console.log('[BubbleManager] Bubble shown for:', matchData.text);
598
+ }
599
+
600
+ /**
601
+ * Create header container for bubble
602
+ * @param {HTMLElement} bubble - Bubble element
603
+ * @param {string} messageState - Message state (error, warning, info)
604
+ */
605
+ createBubbleHeader(bubble, messageState) {
606
+ const headerContainer = document.createElement('div');
607
+ headerContainer.className = 'bubble-header-container';
608
+ headerContainer.setAttribute('data-type', messageState);
609
+
610
+ // Create image
611
+ const img = document.createElement('img');
612
+ img.src = `./assets/trustquery-${messageState}.svg`;
613
+ img.style.height = '24px';
614
+ img.style.width = 'auto';
615
+
616
+ // Create text span
617
+ const span = document.createElement('span');
618
+ const textMap = {
619
+ 'error': 'TrustQuery Stop',
620
+ 'warning': 'TrustQuery Clarify',
621
+ 'info': 'TrustQuery Quick Link'
622
+ };
623
+ span.textContent = textMap[messageState] || 'TrustQuery';
624
+
625
+ // Append to header
626
+ headerContainer.appendChild(img);
627
+ headerContainer.appendChild(span);
628
+
629
+ // Apply styles to header via StyleManager
630
+ if (this.options.styleManager) {
631
+ this.options.styleManager.applyBubbleHeaderStyles(headerContainer, messageState);
632
+ }
633
+
634
+ bubble.appendChild(headerContainer);
635
+ }
636
+
637
+ /**
638
+ * Hide current bubble
639
+ */
640
+ hideBubble() {
641
+ if (this.currentBubble) {
642
+ this.currentBubble.remove();
643
+ this.currentBubble = null;
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Get bubble content using command handler
649
+ * @param {Object} matchData - Match data including command and intent
650
+ * @returns {string|null} HTML content or null
651
+ */
652
+ getBubbleContent(matchData) {
653
+ // Check for new simplified format first (intent.handler.message)
654
+ if (matchData.intent && matchData.intent.handler) {
655
+ const handler = matchData.intent.handler;
656
+ const message = handler.message || handler['message-content'] || matchData.intent.description;
657
+
658
+ if (message) {
659
+ // Return just the message text - header is added separately
660
+ return this.escapeHtml(message);
661
+ }
662
+ }
663
+
664
+ // Use command handler if available (legacy support)
665
+ if (this.options.commandHandlers && matchData.commandType) {
666
+ const content = this.options.commandHandlers.getBubbleContent(matchData.commandType, matchData);
667
+ if (content) {
668
+ return content;
669
+ }
670
+ }
671
+
672
+ // Fallback to legacy method
673
+ const command = matchData.command || {};
674
+
675
+ if (command.content) {
676
+ return command.content;
677
+ }
678
+
679
+ if (command.bubbleContent) {
680
+ return command.bubbleContent;
681
+ }
682
+
683
+ if (command.description) {
684
+ return `<div class="tq-bubble-description">${this.escapeHtml(command.description)}</div>`;
685
+ }
686
+
687
+ return null;
688
+ }
689
+
690
+ /**
691
+ * Position bubble relative to match element
692
+ * @param {HTMLElement} bubble - Bubble element
693
+ * @param {HTMLElement} matchEl - Match element
694
+ */
695
+ positionBubble(bubble, matchEl) {
696
+ const rect = matchEl.getBoundingClientRect();
697
+ const bubbleRect = bubble.getBoundingClientRect();
698
+
699
+ // Position above match by default (since input is at bottom)
700
+ let top = rect.top + window.scrollY - bubbleRect.height - 8;
701
+ let left = rect.left + window.scrollX;
702
+
703
+ // If bubble goes off top edge, position below instead
704
+ if (top < window.scrollY) {
705
+ top = rect.bottom + window.scrollY + 8;
706
+ }
707
+
708
+ // Check if bubble goes off right edge
709
+ if (left + bubbleRect.width > window.innerWidth) {
710
+ left = window.innerWidth - bubbleRect.width - 10;
711
+ }
712
+
713
+ bubble.style.top = `${top}px`;
714
+ bubble.style.left = `${left}px`;
715
+ }
716
+
717
+ /**
718
+ * Escape HTML
719
+ * @param {string} text - Text to escape
720
+ * @returns {string} Escaped text
721
+ */
722
+ escapeHtml(text) {
723
+ const div = document.createElement('div');
724
+ div.textContent = text;
725
+ return div.innerHTML;
726
+ }
727
+
728
+ /**
729
+ * Cleanup
730
+ */
731
+ cleanup() {
732
+ this.hideBubble();
733
+
734
+ if (this.hoverTimeout) {
735
+ clearTimeout(this.hoverTimeout);
736
+ this.hoverTimeout = null;
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Destroy
742
+ */
743
+ destroy() {
744
+ this.cleanup();
745
+ console.log('[BubbleManager] Destroyed');
746
+ }
747
+ }
748
+
749
+ // DropdownManager - Handles dropdown menus with filtering, keyboard navigation, and selection
750
+
751
+ class DropdownManager {
752
+ /**
753
+ * Create dropdown manager
754
+ * @param {Object} options - Configuration
755
+ */
756
+ constructor(options = {}) {
757
+ this.options = {
758
+ styleManager: options.styleManager || null,
759
+ textarea: options.textarea || null,
760
+ onWordClick: options.onWordClick || null,
761
+ dropdownOffset: options.dropdownOffset || 10, // Configurable offset from trigger word
762
+ ...options
763
+ };
764
+
765
+ this.activeDropdown = null;
766
+ this.activeDropdownMatch = null;
767
+ this.dropdownOptions = null;
768
+ this.dropdownMatchData = null;
769
+ this.selectedDropdownIndex = 0;
770
+ this.keyboardHandler = null;
771
+
772
+ console.log('[DropdownManager] Initialized with offset:', this.options.dropdownOffset);
773
+ }
774
+
775
+ /**
776
+ * Show dropdown for a match
777
+ * @param {HTMLElement} matchEl - Match element
778
+ * @param {Object} matchData - Match data
779
+ */
780
+ showDropdown(matchEl, matchData) {
781
+ // Close any existing dropdown
782
+ this.hideDropdown();
783
+
784
+ const command = matchData.command;
785
+
786
+ // Get dropdown options - check intent.handler.options first (new format)
787
+ const options = matchData.intent?.handler?.options || command.options || command.dropdownOptions || [];
788
+
789
+ if (options.length === 0) {
790
+ console.warn('[DropdownManager] No dropdown options for:', matchData.text);
791
+ return;
792
+ }
793
+
794
+ // Store reference to match element
795
+ this.activeDropdownMatch = matchEl;
796
+ this.selectedDropdownIndex = 0;
797
+
798
+ // Create dropdown
799
+ const dropdown = document.createElement('div');
800
+ dropdown.className = 'tq-dropdown';
801
+
802
+ // Apply inline styles via StyleManager
803
+ if (this.options.styleManager) {
804
+ this.options.styleManager.applyDropdownStyles(dropdown);
805
+ }
806
+
807
+ // Add header container based on message-state
808
+ const messageState = matchData.intent?.handler?.['message-state'] || 'info';
809
+ this.createHeaderContainer(dropdown, messageState);
810
+
811
+ // Add description row if message exists
812
+ const message = matchData.intent?.handler?.message;
813
+ if (message) {
814
+ this.createDescriptionRow(dropdown, message, messageState);
815
+ }
816
+
817
+ // Check if filter is enabled
818
+ const hasFilter = matchData.intent?.handler?.filter === true;
819
+
820
+ // Add filter input if enabled
821
+ if (hasFilter) {
822
+ this.createFilterInput(dropdown, matchData);
823
+ }
824
+
825
+ // Create dropdown items
826
+ this.createDropdownItems(dropdown, options, matchData);
827
+
828
+ // Prevent textarea blur when clicking dropdown (except filter input)
829
+ dropdown.addEventListener('mousedown', (e) => {
830
+ // Allow filter input to receive focus naturally
831
+ if (!e.target.classList.contains('tq-dropdown-filter')) {
832
+ e.preventDefault(); // Prevent blur on textarea for items
833
+ }
834
+ });
835
+
836
+ // Add to document
837
+ document.body.appendChild(dropdown);
838
+ this.activeDropdown = dropdown;
839
+ this.dropdownOptions = options;
840
+ this.dropdownMatchData = matchData;
841
+
842
+ // Position dropdown
843
+ this.positionDropdown(dropdown, matchEl);
844
+
845
+ // Setup keyboard navigation
846
+ this.setupKeyboardHandlers();
847
+
848
+ // Close on click outside
849
+ setTimeout(() => {
850
+ document.addEventListener('click', this.closeDropdownHandler);
851
+ }, 0);
852
+
853
+ console.log('[DropdownManager] Dropdown shown with', options.length, 'options');
854
+ }
855
+
856
+ /**
857
+ * Create header container for dropdown
858
+ * @param {HTMLElement} dropdown - Dropdown element
859
+ * @param {string} messageState - Message state (error, warning, info)
860
+ */
861
+ createHeaderContainer(dropdown, messageState) {
862
+ const headerContainer = document.createElement('div');
863
+ headerContainer.className = 'bubble-header-container';
864
+ headerContainer.setAttribute('data-type', messageState);
865
+
866
+ // Create image
867
+ const img = document.createElement('img');
868
+ img.src = `./assets/trustquery-${messageState}.svg`;
869
+ img.style.height = '24px';
870
+ img.style.width = 'auto';
871
+
872
+ // Create text span
873
+ const span = document.createElement('span');
874
+ const textMap = {
875
+ 'error': 'TrustQuery Stop',
876
+ 'warning': 'TrustQuery Clarify',
877
+ 'info': 'TrustQuery Quick Link'
878
+ };
879
+ span.textContent = textMap[messageState] || 'TrustQuery';
880
+
881
+ // Append to header
882
+ headerContainer.appendChild(img);
883
+ headerContainer.appendChild(span);
884
+
885
+ // Apply styles to header via StyleManager
886
+ if (this.options.styleManager) {
887
+ this.options.styleManager.applyDropdownHeaderStyles(headerContainer, messageState);
888
+ }
889
+
890
+ dropdown.appendChild(headerContainer);
891
+ }
892
+
893
+ /**
894
+ * Create description row for dropdown
895
+ * @param {HTMLElement} dropdown - Dropdown element
896
+ * @param {string} message - Description message
897
+ * @param {string} messageState - Message state (error, warning, info)
898
+ */
899
+ createDescriptionRow(dropdown, message, messageState) {
900
+ const descriptionRow = document.createElement('div');
901
+ descriptionRow.className = 'tq-dropdown-description';
902
+ descriptionRow.textContent = message;
903
+
904
+ // Apply styles to description via StyleManager
905
+ if (this.options.styleManager) {
906
+ this.options.styleManager.applyDropdownDescriptionStyles(descriptionRow, messageState);
907
+ }
908
+
909
+ dropdown.appendChild(descriptionRow);
910
+ }
911
+
912
+ /**
913
+ * Create filter input for dropdown
914
+ * @param {HTMLElement} dropdown - Dropdown element
915
+ * @param {Object} matchData - Match data
916
+ */
917
+ createFilterInput(dropdown, matchData) {
918
+ const filterInput = document.createElement('input');
919
+ filterInput.type = 'text';
920
+ filterInput.className = 'tq-dropdown-filter';
921
+ filterInput.placeholder = 'Filter options...';
922
+
923
+ // Apply inline styles via StyleManager
924
+ if (this.options.styleManager) {
925
+ this.options.styleManager.applyDropdownFilterStyles(filterInput);
926
+ }
927
+
928
+ // Filter click handling is managed by dropdown and closeDropdownHandler
929
+ // No special mousedown/blur handling needed - filter focuses naturally
930
+
931
+ // Filter options as user types
932
+ filterInput.addEventListener('input', (e) => {
933
+ const query = e.target.value.toLowerCase();
934
+ const items = dropdown.querySelectorAll('.tq-dropdown-item');
935
+ let firstVisibleIndex = -1;
936
+
937
+ items.forEach((item, index) => {
938
+ const text = item.textContent.toLowerCase();
939
+ const matches = text.includes(query);
940
+ item.style.display = matches ? 'block' : 'none';
941
+
942
+ // Track first visible item for selection
943
+ if (matches && firstVisibleIndex === -1) {
944
+ firstVisibleIndex = index;
945
+ }
946
+ });
947
+
948
+ // Update selected index to first visible item
949
+ if (firstVisibleIndex !== -1) {
950
+ this.selectedDropdownIndex = firstVisibleIndex;
951
+ this.updateDropdownSelection();
952
+ }
953
+ });
954
+
955
+ // Prevent dropdown keyboard navigation from affecting filter input
956
+ filterInput.addEventListener('keydown', (e) => {
957
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
958
+ e.preventDefault();
959
+ e.stopPropagation();
960
+ // Move focus to dropdown items
961
+ filterInput.blur();
962
+ this.options.textarea.focus();
963
+ } else if (e.key === 'Enter') {
964
+ e.preventDefault();
965
+ e.stopPropagation();
966
+ this.selectCurrentDropdownItem();
967
+ } else if (e.key === 'Escape') {
968
+ e.preventDefault();
969
+ e.stopPropagation();
970
+ this.hideDropdown();
971
+ }
972
+ });
973
+
974
+ dropdown.appendChild(filterInput);
975
+
976
+ // Don't auto-focus - keep focus on textarea for natural typing/arrow key flow
977
+ }
978
+
979
+ /**
980
+ * Create dropdown items
981
+ * @param {HTMLElement} dropdown - Dropdown element
982
+ * @param {Array} options - Dropdown options
983
+ * @param {Object} matchData - Match data
984
+ */
985
+ createDropdownItems(dropdown, options, matchData) {
986
+ options.forEach((option, index) => {
987
+ // Check if this is a user-input option
988
+ if (typeof option === 'object' && option['user-input'] === true) {
989
+ this.createUserInputOption(dropdown, option, matchData);
990
+ return;
991
+ }
992
+
993
+ const item = document.createElement('div');
994
+ item.className = 'tq-dropdown-item';
995
+ item.textContent = typeof option === 'string' ? option : option.label || option.value;
996
+
997
+ // Highlight first item by default
998
+ if (index === 0) {
999
+ item.classList.add('tq-dropdown-item-selected');
1000
+ }
1001
+
1002
+ // Apply inline styles to item via StyleManager
1003
+ if (this.options.styleManager) {
1004
+ this.options.styleManager.applyDropdownItemStyles(item);
1005
+ }
1006
+
1007
+ item.addEventListener('click', (e) => {
1008
+ e.stopPropagation();
1009
+ this.handleDropdownSelect(option, matchData);
1010
+ this.hideDropdown();
1011
+ });
1012
+ dropdown.appendChild(item);
1013
+ });
1014
+ }
1015
+
1016
+ /**
1017
+ * Create user input option
1018
+ * @param {HTMLElement} dropdown - Dropdown element
1019
+ * @param {Object} option - Option with user-input flag
1020
+ * @param {Object} matchData - Match data
1021
+ */
1022
+ createUserInputOption(dropdown, option, matchData) {
1023
+ const container = document.createElement('div');
1024
+ container.className = 'tq-dropdown-user-input-container tq-dropdown-item';
1025
+ container.setAttribute('data-user-input', 'true');
1026
+
1027
+ const input = document.createElement('input');
1028
+ input.type = 'text';
1029
+ input.className = 'tq-dropdown-user-input';
1030
+ input.placeholder = option.placeholder || 'Enter custom value...';
1031
+
1032
+ container.appendChild(input);
1033
+
1034
+ // Apply styles via StyleManager
1035
+ if (this.options.styleManager) {
1036
+ this.options.styleManager.applyUserInputStyles(container, input);
1037
+ }
1038
+
1039
+ // Click on container focuses input
1040
+ container.addEventListener('click', (e) => {
1041
+ e.stopPropagation();
1042
+ input.focus();
1043
+ });
1044
+
1045
+ // Handle submit on Enter key
1046
+ const handleSubmit = () => {
1047
+ const value = input.value.trim();
1048
+ if (value) {
1049
+ const customOption = {
1050
+ label: value,
1051
+ 'on-select': {
1052
+ display: value
1053
+ }
1054
+ };
1055
+ this.handleDropdownSelect(customOption, matchData);
1056
+ this.hideDropdown();
1057
+ }
1058
+ };
1059
+
1060
+ input.addEventListener('keydown', (e) => {
1061
+ if (e.key === 'Enter') {
1062
+ e.preventDefault();
1063
+ e.stopPropagation();
1064
+ handleSubmit();
1065
+ } else if (e.key === 'Escape') {
1066
+ e.preventDefault();
1067
+ e.stopPropagation();
1068
+ this.hideDropdown();
1069
+ } else if (e.key === 'ArrowDown') {
1070
+ // Navigate to next item
1071
+ e.preventDefault();
1072
+ e.stopPropagation();
1073
+ this.moveDropdownSelection(1);
1074
+ this.options.textarea.focus();
1075
+ } else if (e.key === 'ArrowUp') {
1076
+ // Navigate to previous item
1077
+ e.preventDefault();
1078
+ e.stopPropagation();
1079
+ this.moveDropdownSelection(-1);
1080
+ this.options.textarea.focus();
1081
+ }
1082
+ });
1083
+
1084
+ dropdown.appendChild(container);
1085
+ }
1086
+
1087
+ /**
1088
+ * Hide current dropdown
1089
+ */
1090
+ hideDropdown() {
1091
+ if (this.activeDropdown) {
1092
+ this.activeDropdown.remove();
1093
+ this.activeDropdown = null;
1094
+ this.activeDropdownMatch = null;
1095
+ this.dropdownOptions = null;
1096
+ this.dropdownMatchData = null;
1097
+ this.selectedDropdownIndex = 0;
1098
+ document.removeEventListener('click', this.closeDropdownHandler);
1099
+ document.removeEventListener('keydown', this.keyboardHandler);
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Close dropdown handler (bound to document)
1105
+ */
1106
+ closeDropdownHandler = (e) => {
1107
+ // Only close if clicking outside the dropdown
1108
+ if (this.activeDropdown && !this.activeDropdown.contains(e.target)) {
1109
+ this.hideDropdown();
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Position dropdown relative to match element
1115
+ * @param {HTMLElement} dropdown - Dropdown element
1116
+ * @param {HTMLElement} matchEl - Match element
1117
+ */
1118
+ positionDropdown(dropdown, matchEl) {
1119
+ const rect = matchEl.getBoundingClientRect();
1120
+ const dropdownRect = dropdown.getBoundingClientRect();
1121
+ const offset = this.options.dropdownOffset;
1122
+
1123
+ // Position above match by default (since input is at bottom)
1124
+ let top = rect.top + window.scrollY - dropdownRect.height - offset;
1125
+ let left = rect.left + window.scrollX;
1126
+
1127
+ // If dropdown goes off top edge, position below instead
1128
+ if (top < window.scrollY) {
1129
+ top = rect.bottom + window.scrollY + offset;
1130
+ }
1131
+
1132
+ // Check if dropdown goes off right edge
1133
+ if (left + dropdownRect.width > window.innerWidth) {
1134
+ left = window.innerWidth - dropdownRect.width - 10;
1135
+ }
1136
+
1137
+ dropdown.style.top = `${top}px`;
1138
+ dropdown.style.left = `${left}px`;
1139
+ }
1140
+
1141
+ /**
1142
+ * Setup keyboard handlers for dropdown navigation
1143
+ */
1144
+ setupKeyboardHandlers() {
1145
+ // Remove old handler if exists
1146
+ if (this.keyboardHandler) {
1147
+ document.removeEventListener('keydown', this.keyboardHandler);
1148
+ }
1149
+
1150
+ // Create new handler
1151
+ this.keyboardHandler = (e) => {
1152
+ // Only handle if dropdown is active
1153
+ if (!this.activeDropdown) {
1154
+ return;
1155
+ }
1156
+
1157
+ switch (e.key) {
1158
+ case 'ArrowDown':
1159
+ e.preventDefault();
1160
+ this.moveDropdownSelection(1);
1161
+ break;
1162
+ case 'ArrowUp':
1163
+ e.preventDefault();
1164
+ this.moveDropdownSelection(-1);
1165
+ break;
1166
+ case 'Enter':
1167
+ e.preventDefault();
1168
+ this.selectCurrentDropdownItem();
1169
+ break;
1170
+ case 'Escape':
1171
+ e.preventDefault();
1172
+ this.hideDropdown();
1173
+ break;
1174
+ }
1175
+ };
1176
+
1177
+ // Attach handler
1178
+ document.addEventListener('keydown', this.keyboardHandler);
1179
+ }
1180
+
1181
+ /**
1182
+ * Move dropdown selection up or down
1183
+ * @param {number} direction - 1 for down, -1 for up
1184
+ */
1185
+ moveDropdownSelection(direction) {
1186
+ if (!this.activeDropdown || !this.dropdownOptions) {
1187
+ return;
1188
+ }
1189
+
1190
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
1191
+ const visibleItems = Array.from(items).filter(item => item.style.display !== 'none');
1192
+
1193
+ if (visibleItems.length === 0) {
1194
+ return;
1195
+ }
1196
+
1197
+ // Find current selected visible index
1198
+ let currentVisibleIndex = visibleItems.findIndex((item, index) => {
1199
+ return Array.from(items).indexOf(item) === this.selectedDropdownIndex;
1200
+ });
1201
+
1202
+ // Move to next/prev visible item
1203
+ currentVisibleIndex += direction;
1204
+
1205
+ // Wrap around visible items only
1206
+ if (currentVisibleIndex < 0) {
1207
+ currentVisibleIndex = visibleItems.length - 1;
1208
+ } else if (currentVisibleIndex >= visibleItems.length) {
1209
+ currentVisibleIndex = 0;
1210
+ }
1211
+
1212
+ // Update selected index to the actual index
1213
+ this.selectedDropdownIndex = Array.from(items).indexOf(visibleItems[currentVisibleIndex]);
1214
+
1215
+ // Update visual selection
1216
+ this.updateDropdownSelection();
1217
+ }
1218
+
1219
+ /**
1220
+ * Update visual selection in dropdown
1221
+ */
1222
+ updateDropdownSelection() {
1223
+ if (!this.activeDropdown) {
1224
+ return;
1225
+ }
1226
+
1227
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
1228
+ items.forEach((item, index) => {
1229
+ if (index === this.selectedDropdownIndex) {
1230
+ item.classList.add('tq-dropdown-item-selected');
1231
+ // Scroll into view if needed
1232
+ item.scrollIntoView({ block: 'nearest' });
1233
+
1234
+ // If this is a user input item, focus the input
1235
+ if (item.getAttribute('data-user-input') === 'true') {
1236
+ const input = item.querySelector('.tq-dropdown-user-input');
1237
+ if (input) {
1238
+ setTimeout(() => input.focus(), 0);
1239
+ }
1240
+ }
1241
+ } else {
1242
+ item.classList.remove('tq-dropdown-item-selected');
1243
+
1244
+ // If this is a user input item, blur the input when navigating away
1245
+ if (item.getAttribute('data-user-input') === 'true') {
1246
+ const input = item.querySelector('.tq-dropdown-user-input');
1247
+ if (input) {
1248
+ input.blur();
1249
+ }
1250
+ }
1251
+ }
1252
+ });
1253
+ }
1254
+
1255
+ /**
1256
+ * Select the currently highlighted dropdown item
1257
+ */
1258
+ selectCurrentDropdownItem() {
1259
+ if (!this.dropdownOptions || !this.dropdownMatchData) {
1260
+ return;
1261
+ }
1262
+
1263
+ const items = this.activeDropdown.querySelectorAll('.tq-dropdown-item');
1264
+ const selectedItem = items[this.selectedDropdownIndex];
1265
+
1266
+ // Check if this is a user input item
1267
+ if (selectedItem && selectedItem.getAttribute('data-user-input') === 'true') {
1268
+ // Focus the input field instead of selecting
1269
+ const input = selectedItem.querySelector('.tq-dropdown-user-input');
1270
+ if (input) {
1271
+ input.focus();
1272
+ }
1273
+ return;
1274
+ }
1275
+
1276
+ const selectedOption = this.dropdownOptions[this.selectedDropdownIndex];
1277
+ this.handleDropdownSelect(selectedOption, this.dropdownMatchData);
1278
+ this.hideDropdown();
1279
+ }
1280
+
1281
+ /**
1282
+ * Handle dropdown option selection
1283
+ * @param {*} option - Selected option
1284
+ * @param {Object} matchData - Match data
1285
+ */
1286
+ handleDropdownSelect(option, matchData) {
1287
+ console.log('[DropdownManager] Dropdown option selected:', option, 'for:', matchData.text);
1288
+
1289
+ // Check if option has on-select.display
1290
+ if (option['on-select'] && option['on-select'].display && this.options.textarea) {
1291
+ const displayText = option['on-select'].display;
1292
+ const textarea = this.options.textarea;
1293
+ const text = textarea.value;
1294
+
1295
+ // Find the trigger text position
1296
+ const lines = text.split('\n');
1297
+ const line = lines[matchData.line];
1298
+
1299
+ if (line) {
1300
+ // Append to trigger text with "/" separator
1301
+ const before = line.substring(0, matchData.col);
1302
+ const after = line.substring(matchData.col + matchData.text.length);
1303
+ const newText = matchData.text + '/' + displayText;
1304
+ lines[matchData.line] = before + newText + after;
1305
+
1306
+ // Update textarea
1307
+ textarea.value = lines.join('\n');
1308
+
1309
+ // Trigger input event to re-render
1310
+ const inputEvent = new Event('input', { bubbles: true });
1311
+ textarea.dispatchEvent(inputEvent);
1312
+
1313
+ console.log('[DropdownManager] Appended to', matchData.text, '→', newText);
1314
+ }
1315
+ }
1316
+
1317
+ // Trigger callback
1318
+ if (this.options.onWordClick) {
1319
+ this.options.onWordClick({
1320
+ ...matchData,
1321
+ selectedOption: option
1322
+ });
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * Cleanup
1328
+ */
1329
+ cleanup() {
1330
+ this.hideDropdown();
1331
+ }
1332
+
1333
+ /**
1334
+ * Destroy
1335
+ */
1336
+ destroy() {
1337
+ this.cleanup();
1338
+ console.log('[DropdownManager] Destroyed');
1339
+ }
1340
+ }
1341
+
1342
+ // InteractionHandler - Orchestrates hover bubbles and click interactions on matched words
1343
+ // Delegates to BubbleManager and DropdownManager for specific functionality
1344
+
1345
+
1346
+ class InteractionHandler {
1347
+ /**
1348
+ * Create interaction handler
1349
+ * @param {HTMLElement} overlay - Overlay element containing matches
1350
+ * @param {Object} options - Configuration
1351
+ */
1352
+ constructor(overlay, options = {}) {
1353
+ this.overlay = overlay;
1354
+ this.options = {
1355
+ bubbleDelay: options.bubbleDelay || 200,
1356
+ onWordClick: options.onWordClick || null,
1357
+ onWordHover: options.onWordHover || null,
1358
+ styleManager: options.styleManager || null,
1359
+ commandHandlers: options.commandHandlers || null,
1360
+ textarea: options.textarea || null,
1361
+ ...options
1362
+ };
1363
+
1364
+ this.hoverTimeout = null;
1365
+
1366
+ // Create manager instances
1367
+ this.bubbleManager = new BubbleManager({
1368
+ bubbleDelay: this.options.bubbleDelay,
1369
+ styleManager: this.options.styleManager,
1370
+ commandHandlers: this.options.commandHandlers
1371
+ });
1372
+
1373
+ this.dropdownManager = new DropdownManager({
1374
+ styleManager: this.options.styleManager,
1375
+ textarea: this.options.textarea,
1376
+ onWordClick: this.options.onWordClick,
1377
+ dropdownOffset: this.options.dropdownOffset
1378
+ });
1379
+
1380
+ console.log('[InteractionHandler] Initialized');
1381
+ }
1382
+
1383
+ /**
1384
+ * Update handlers after overlay re-render
1385
+ * Attach event listeners to all .tq-match elements
1386
+ */
1387
+ update() {
1388
+ // Remove old handlers (if any)
1389
+ this.cleanup();
1390
+
1391
+ // Find all match elements
1392
+ const matches = this.overlay.querySelectorAll('.tq-match');
1393
+
1394
+ matches.forEach(matchEl => {
1395
+ const behavior = matchEl.getAttribute('data-behavior');
1396
+
1397
+ // Hover events for bubbles
1398
+ matchEl.addEventListener('mouseenter', (e) => this.handleMouseEnter(e, matchEl));
1399
+ matchEl.addEventListener('mouseleave', (e) => this.handleMouseLeave(e, matchEl));
1400
+
1401
+ // Click events for dropdowns/actions
1402
+ matchEl.addEventListener('click', (e) => this.handleClick(e, matchEl));
1403
+
1404
+ // Add hover class for CSS styling
1405
+ matchEl.classList.add('tq-hoverable');
1406
+
1407
+ // Auto-show dropdown for dropdown-behavior matches
1408
+ if (behavior === 'dropdown') {
1409
+ const matchData = this.getMatchData(matchEl);
1410
+ // Only show if this isn't the currently active dropdown match
1411
+ if (!this.dropdownManager.activeDropdownMatch ||
1412
+ this.dropdownManager.activeDropdownMatch.textContent !== matchEl.textContent) {
1413
+ this.dropdownManager.showDropdown(matchEl, matchData);
1414
+ }
1415
+ }
1416
+ });
1417
+
1418
+ console.log('[InteractionHandler] Updated with', matches.length, 'interactive elements');
1419
+ }
1420
+
1421
+ /**
1422
+ * Handle mouse enter on a match
1423
+ * @param {Event} e - Mouse event
1424
+ * @param {HTMLElement} matchEl - Match element
1425
+ */
1426
+ handleMouseEnter(e, matchEl) {
1427
+ const behavior = matchEl.getAttribute('data-behavior');
1428
+
1429
+ // Only show bubble if behavior is 'bubble' or 'hover'
1430
+ if (behavior === 'bubble' || behavior === 'hover') {
1431
+ // Clear any existing timeout
1432
+ if (this.hoverTimeout) {
1433
+ clearTimeout(this.hoverTimeout);
1434
+ }
1435
+
1436
+ // Delay bubble appearance
1437
+ this.hoverTimeout = setTimeout(() => {
1438
+ const matchData = this.getMatchData(matchEl);
1439
+ this.bubbleManager.showBubble(matchEl, matchData);
1440
+ }, this.options.bubbleDelay);
1441
+ }
1442
+
1443
+ // Callback
1444
+ if (this.options.onWordHover) {
1445
+ const matchData = this.getMatchData(matchEl);
1446
+ this.options.onWordHover(matchData);
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * Handle mouse leave from a match
1452
+ * @param {Event} e - Mouse event
1453
+ * @param {HTMLElement} matchEl - Match element
1454
+ */
1455
+ handleMouseLeave(e, matchEl) {
1456
+ // Clear hover timeout
1457
+ if (this.hoverTimeout) {
1458
+ clearTimeout(this.hoverTimeout);
1459
+ this.hoverTimeout = null;
1460
+ }
1461
+
1462
+ // Don't immediately hide bubble - let user hover over it
1463
+ // Bubble will auto-hide when mouse leaves bubble area
1464
+ }
1465
+
1466
+ /**
1467
+ * Handle click on a match
1468
+ * @param {Event} e - Click event
1469
+ * @param {HTMLElement} matchEl - Match element
1470
+ */
1471
+ handleClick(e, matchEl) {
1472
+ e.preventDefault();
1473
+ e.stopPropagation();
1474
+
1475
+ const behavior = matchEl.getAttribute('data-behavior');
1476
+ const matchData = this.getMatchData(matchEl);
1477
+
1478
+ console.log('[InteractionHandler] Match clicked:', matchData);
1479
+
1480
+ // Handle different behaviors
1481
+ if (behavior === 'dropdown') {
1482
+ // Toggle dropdown - close if already open for this match, otherwise show
1483
+ if (this.dropdownManager.activeDropdownMatch === matchEl) {
1484
+ this.dropdownManager.hideDropdown();
1485
+ } else {
1486
+ this.dropdownManager.showDropdown(matchEl, matchData);
1487
+ }
1488
+ } else if (behavior === 'action') {
1489
+ // Custom action callback
1490
+ if (this.options.onWordClick) {
1491
+ this.options.onWordClick(matchData);
1492
+ }
1493
+ }
1494
+
1495
+ // Always trigger callback (unless it's a dropdown toggle to close)
1496
+ if (this.options.onWordClick && !(behavior === 'dropdown' && this.dropdownManager.activeDropdownMatch === matchEl)) {
1497
+ this.options.onWordClick(matchData);
1498
+ }
1499
+ }
1500
+
1501
+ /**
1502
+ * Hide dropdown (exposed for external use, e.g., when textarea loses focus)
1503
+ */
1504
+ hideDropdown() {
1505
+ this.dropdownManager.hideDropdown();
1506
+ }
1507
+
1508
+ /**
1509
+ * Extract match data from element
1510
+ * @param {HTMLElement} matchEl - Match element
1511
+ * @returns {Object} Match data
1512
+ */
1513
+ getMatchData(matchEl) {
1514
+ // Parse intent JSON if available
1515
+ let intent = null;
1516
+ const intentStr = matchEl.getAttribute('data-intent');
1517
+ if (intentStr) {
1518
+ try {
1519
+ intent = JSON.parse(intentStr);
1520
+ } catch (e) {
1521
+ console.warn('[InteractionHandler] Failed to parse intent JSON:', e);
1522
+ }
1523
+ }
1524
+
1525
+ return {
1526
+ text: matchEl.getAttribute('data-match-text'),
1527
+ line: parseInt(matchEl.getAttribute('data-line')),
1528
+ col: parseInt(matchEl.getAttribute('data-col')),
1529
+ commandType: matchEl.getAttribute('data-command-type'),
1530
+ commandPath: matchEl.getAttribute('data-command-path'),
1531
+ intentPath: matchEl.getAttribute('data-intent-path'),
1532
+ intent: intent,
1533
+ command: {
1534
+ id: matchEl.getAttribute('data-command-id'),
1535
+ type: matchEl.getAttribute('data-command-type'),
1536
+ behavior: matchEl.getAttribute('data-behavior')
1537
+ },
1538
+ element: matchEl
1539
+ };
1540
+ }
1541
+
1542
+ /**
1543
+ * Cleanup event listeners
1544
+ */
1545
+ cleanup() {
1546
+ this.bubbleManager.cleanup();
1547
+ this.dropdownManager.cleanup();
1548
+
1549
+ if (this.hoverTimeout) {
1550
+ clearTimeout(this.hoverTimeout);
1551
+ this.hoverTimeout = null;
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Destroy handler
1557
+ */
1558
+ destroy() {
1559
+ this.cleanup();
1560
+ this.bubbleManager.destroy();
1561
+ this.dropdownManager.destroy();
1562
+ console.log('[InteractionHandler] Destroyed');
1563
+ }
1564
+ }
1565
+
1566
+ // StyleManager - Handles all inline styling for textarea, wrapper, and overlay
1567
+ // Makes TrustQuery completely self-contained without requiring external CSS
1568
+
1569
+ class StyleManager {
1570
+ /**
1571
+ * Create style manager
1572
+ * @param {Object} options - Theme and style options
1573
+ */
1574
+ constructor(options = {}) {
1575
+ this.options = {
1576
+ // Theme colors
1577
+ backgroundColor: options.backgroundColor || '#fff',
1578
+ textColor: options.textColor || '#333',
1579
+ caretColor: options.caretColor || '#000',
1580
+ borderColor: options.borderColor || '#ddd',
1581
+ borderColorFocus: options.borderColorFocus || '#4a90e2',
1582
+
1583
+ // Match colors (can be overridden)
1584
+ matchBackgroundColor: options.matchBackgroundColor || 'rgba(74, 144, 226, 0.15)',
1585
+ matchTextColor: options.matchTextColor || '#2b6cb0',
1586
+ matchHoverBackgroundColor: options.matchHoverBackgroundColor || 'rgba(74, 144, 226, 0.25)',
1587
+
1588
+ // Font settings (optional, will use textarea's if not specified)
1589
+ fontFamily: options.fontFamily || null,
1590
+ fontSize: options.fontSize || null,
1591
+ lineHeight: options.lineHeight || null,
1592
+
1593
+ ...options
1594
+ };
1595
+
1596
+ console.log('[StyleManager] Initialized with theme:', this.options);
1597
+ }
1598
+
1599
+ /**
1600
+ * Apply all styles to wrapper, textarea, and overlay
1601
+ * @param {HTMLElement} wrapper - Wrapper element
1602
+ * @param {HTMLElement} textarea - Textarea element
1603
+ * @param {HTMLElement} overlay - Overlay element
1604
+ */
1605
+ applyAllStyles(wrapper, textarea, overlay) {
1606
+ // Get computed styles from original textarea
1607
+ const computedStyle = window.getComputedStyle(textarea);
1608
+
1609
+ // Apply styles to each element
1610
+ this.applyWrapperStyles(wrapper, computedStyle);
1611
+ this.applyTextareaStyles(textarea, computedStyle);
1612
+ this.applyOverlayStyles(overlay, computedStyle);
1613
+
1614
+ // Apply focus handlers
1615
+ this.setupFocusStyles(wrapper, textarea);
1616
+
1617
+ console.log('[StyleManager] All styles applied');
1618
+ }
1619
+
1620
+ /**
1621
+ * Apply wrapper styles (container for both textarea and overlay)
1622
+ * @param {HTMLElement} wrapper - Wrapper element
1623
+ * @param {CSSStyleDeclaration} computedStyle - Computed styles from textarea
1624
+ */
1625
+ applyWrapperStyles(wrapper, computedStyle) {
1626
+ Object.assign(wrapper.style, {
1627
+ position: 'relative',
1628
+ display: 'block',
1629
+ width: '100%',
1630
+ background: this.options.backgroundColor,
1631
+ border: `1px solid ${this.options.borderColor}`,
1632
+ borderRadius: '4px',
1633
+ boxSizing: 'border-box',
1634
+ transition: 'border-color 0.15s ease, box-shadow 0.15s ease'
1635
+ });
1636
+ }
1637
+
1638
+ /**
1639
+ * Apply textarea styles (transparent text, visible caret)
1640
+ * @param {HTMLElement} textarea - Textarea element
1641
+ * @param {CSSStyleDeclaration} computedStyle - Original computed styles
1642
+ */
1643
+ applyTextareaStyles(textarea, computedStyle) {
1644
+ // Use provided font settings or fall back to computed/defaults
1645
+ const fontFamily = this.options.fontFamily || computedStyle.fontFamily || "'Courier New', monospace";
1646
+ const fontSize = this.options.fontSize || computedStyle.fontSize || '14px';
1647
+ const lineHeight = this.options.lineHeight || computedStyle.lineHeight || '1.5';
1648
+ const padding = computedStyle.padding || '12px';
1649
+
1650
+ // Store existing transition if any (for FOUC prevention)
1651
+ const existingTransition = textarea.style.transition;
1652
+
1653
+ Object.assign(textarea.style, {
1654
+ fontFamily,
1655
+ fontSize,
1656
+ lineHeight,
1657
+ padding,
1658
+ border: 'none',
1659
+ borderRadius: '0',
1660
+ background: 'transparent',
1661
+ color: this.options.textColor, // Set color for caret visibility
1662
+ WebkitTextFillColor: 'transparent', // Make text transparent but keep caret
1663
+ caretColor: this.options.caretColor,
1664
+ resize: 'none',
1665
+ width: '100%',
1666
+ boxSizing: 'border-box',
1667
+ position: 'relative',
1668
+ zIndex: '0', // Below overlay so hover events reach overlay matches
1669
+ whiteSpace: 'pre-wrap',
1670
+ wordWrap: 'break-word',
1671
+ overflowWrap: 'break-word',
1672
+ outline: 'none',
1673
+ margin: '0',
1674
+ transition: existingTransition // Preserve opacity transition from HTML
1675
+ });
1676
+
1677
+ // Add CSS to make placeholder visible (not affected by -webkit-text-fill-color)
1678
+ this.ensurePlaceholderStyles();
1679
+
1680
+ // Store computed values for overlay to use
1681
+ this._textareaStyles = {
1682
+ fontFamily,
1683
+ fontSize,
1684
+ lineHeight,
1685
+ padding
1686
+ };
1687
+ }
1688
+
1689
+ /**
1690
+ * Apply overlay styles (must match textarea exactly for alignment)
1691
+ * @param {HTMLElement} overlay - Overlay element
1692
+ * @param {CSSStyleDeclaration} computedStyle - Computed styles from textarea
1693
+ */
1694
+ applyOverlayStyles(overlay, computedStyle) {
1695
+ // Use the stored textarea styles to ensure perfect alignment
1696
+ const { fontFamily, fontSize, lineHeight, padding } = this._textareaStyles;
1697
+
1698
+ Object.assign(overlay.style, {
1699
+ position: 'absolute',
1700
+ top: '0',
1701
+ left: '0',
1702
+ right: '0',
1703
+ bottom: '0',
1704
+ fontFamily,
1705
+ fontSize,
1706
+ lineHeight,
1707
+ padding,
1708
+ color: this.options.textColor,
1709
+ pointerEvents: 'none', // Let clicks pass through to textarea (except on match spans with pointer-events: auto)
1710
+ overflow: 'hidden',
1711
+ whiteSpace: 'pre-wrap',
1712
+ wordWrap: 'break-word',
1713
+ overflowWrap: 'break-word',
1714
+ zIndex: '1', // Above textarea so match spans can receive hover/click events
1715
+ boxSizing: 'border-box',
1716
+ margin: '0'
1717
+ });
1718
+ }
1719
+
1720
+ /**
1721
+ * Setup focus styles (apply on focus, remove on blur)
1722
+ * @param {HTMLElement} wrapper - Wrapper element
1723
+ * @param {HTMLElement} textarea - Textarea element
1724
+ */
1725
+ setupFocusStyles(wrapper, textarea) {
1726
+ textarea.addEventListener('focus', () => {
1727
+ wrapper.style.borderColor = this.options.borderColorFocus;
1728
+ wrapper.style.boxShadow = `0 0 0 3px ${this.options.borderColorFocus}1a`; // 1a = 10% opacity
1729
+ });
1730
+
1731
+ textarea.addEventListener('blur', () => {
1732
+ wrapper.style.borderColor = this.options.borderColor;
1733
+ wrapper.style.boxShadow = 'none';
1734
+ });
1735
+ }
1736
+
1737
+ /**
1738
+ * Apply match (highlighted word) styles
1739
+ * @param {HTMLElement} matchElement - Span element for matched word
1740
+ * @param {string} matchType - Type of match (keyword, mention, command, etc.)
1741
+ */
1742
+ applyMatchStyles(matchElement, matchType = 'default') {
1743
+ // Base match styles
1744
+ Object.assign(matchElement.style, {
1745
+ pointerEvents: 'auto', // Enable interactions
1746
+ cursor: 'pointer',
1747
+ padding: '2px 4px',
1748
+ margin: '-2px -4px',
1749
+ borderRadius: '3px',
1750
+ transition: 'background-color 0.15s ease',
1751
+ backgroundColor: this.options.matchBackgroundColor,
1752
+ color: this.options.matchTextColor
1753
+ });
1754
+
1755
+ // Hover styles (on mouseover)
1756
+ matchElement.addEventListener('mouseenter', () => {
1757
+ matchElement.style.backgroundColor = this.options.matchHoverBackgroundColor;
1758
+ });
1759
+
1760
+ matchElement.addEventListener('mouseleave', () => {
1761
+ matchElement.style.backgroundColor = this.options.matchBackgroundColor;
1762
+ });
1763
+ }
1764
+
1765
+ /**
1766
+ * Apply bubble (tooltip) styles
1767
+ * @param {HTMLElement} bubble - Bubble element
1768
+ */
1769
+ applyBubbleStyles(bubble) {
1770
+ Object.assign(bubble.style, {
1771
+ position: 'absolute',
1772
+ background: '#ffffff',
1773
+ border: `1px solid ${this.options.borderColor}`,
1774
+ borderRadius: '6px',
1775
+ padding: '0',
1776
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
1777
+ zIndex: '10000',
1778
+ maxWidth: '300px',
1779
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1780
+ fontSize: '13px',
1781
+ lineHeight: '1.4',
1782
+ color: this.options.textColor,
1783
+ pointerEvents: 'auto',
1784
+ opacity: '1',
1785
+ overflow: 'hidden',
1786
+ animation: 'tq-bubble-appear 0.15s ease-out'
1787
+ });
1788
+
1789
+ // Add animation keyframes if not already added
1790
+ this.ensureAnimationStyles();
1791
+
1792
+ // Style the content container
1793
+ const contentContainer = bubble.querySelector('.tq-bubble-content');
1794
+ if (contentContainer) {
1795
+ Object.assign(contentContainer.style, {
1796
+ padding: '8px 12px',
1797
+ fontSize: '12px',
1798
+ lineHeight: '1.4'
1799
+ });
1800
+ }
1801
+ }
1802
+
1803
+ /**
1804
+ * Apply bubble header styles
1805
+ * @param {HTMLElement} header - Header container element
1806
+ * @param {string} messageState - Message state (error, warning, info)
1807
+ */
1808
+ applyBubbleHeaderStyles(header, messageState) {
1809
+ const colorMap = {
1810
+ 'error': '#991b1b',
1811
+ 'warning': '#92400e',
1812
+ 'info': '#065f46'
1813
+ };
1814
+
1815
+ const bgColorMap = {
1816
+ 'error': '#fee2e2',
1817
+ 'warning': '#fef3c7',
1818
+ 'info': '#d1fae5'
1819
+ };
1820
+
1821
+ const color = colorMap[messageState] || '#2b6cb0';
1822
+ const bgColor = bgColorMap[messageState] || '#e0f2fe';
1823
+
1824
+ Object.assign(header.style, {
1825
+ display: 'flex',
1826
+ alignItems: 'center',
1827
+ gap: '8px',
1828
+ padding: '10px 12px',
1829
+ backgroundColor: bgColor,
1830
+ color: color,
1831
+ fontWeight: '600',
1832
+ fontSize: '11px',
1833
+ borderBottom: `1px solid ${this.options.borderColor}`
1834
+ });
1835
+ }
1836
+
1837
+ /**
1838
+ * Apply dropdown (menu) styles
1839
+ * @param {HTMLElement} dropdown - Dropdown element
1840
+ */
1841
+ applyDropdownStyles(dropdown) {
1842
+ Object.assign(dropdown.style, {
1843
+ position: 'absolute',
1844
+ background: '#ffffff',
1845
+ border: `1px solid ${this.options.borderColor}`,
1846
+ borderRadius: '6px',
1847
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
1848
+ zIndex: '10000',
1849
+ minWidth: '150px',
1850
+ maxWidth: '300px',
1851
+ overflow: 'hidden',
1852
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1853
+ fontSize: '14px',
1854
+ opacity: '1',
1855
+ animation: 'tq-dropdown-appear 0.15s ease-out'
1856
+ });
1857
+
1858
+ this.ensureAnimationStyles();
1859
+ }
1860
+
1861
+ /**
1862
+ * Apply dropdown header styles
1863
+ * @param {HTMLElement} header - Header container element
1864
+ * @param {string} messageState - Message state (error, warning, info)
1865
+ */
1866
+ applyDropdownHeaderStyles(header, messageState) {
1867
+ const colorMap = {
1868
+ 'error': '#991b1b',
1869
+ 'warning': '#92400e',
1870
+ 'info': '#065f46'
1871
+ };
1872
+
1873
+ const bgColorMap = {
1874
+ 'error': '#fee2e2',
1875
+ 'warning': '#fef3c7',
1876
+ 'info': '#d1fae5'
1877
+ };
1878
+
1879
+ const color = colorMap[messageState] || '#2b6cb0';
1880
+ const bgColor = bgColorMap[messageState] || '#e0f2fe';
1881
+
1882
+ Object.assign(header.style, {
1883
+ display: 'flex',
1884
+ alignItems: 'center',
1885
+ gap: '8px',
1886
+ padding: '10px 12px',
1887
+ backgroundColor: bgColor,
1888
+ color: color,
1889
+ fontWeight: '600',
1890
+ fontSize: '11px',
1891
+ borderBottom: `1px solid ${this.options.borderColor}`
1892
+ });
1893
+ }
1894
+
1895
+ /**
1896
+ * Apply dropdown description styles
1897
+ * @param {HTMLElement} description - Description element
1898
+ * @param {string} messageState - Message state (error, warning, info)
1899
+ */
1900
+ applyDropdownDescriptionStyles(description, messageState) {
1901
+ Object.assign(description.style, {
1902
+ padding: '8px 12px',
1903
+ fontSize: '12px',
1904
+ lineHeight: '1.5',
1905
+ color: '#4a5568',
1906
+ backgroundColor: '#f7fafc',
1907
+ borderBottom: `1px solid ${this.options.borderColor}`
1908
+ });
1909
+ }
1910
+
1911
+ /**
1912
+ * Apply dropdown item styles
1913
+ * @param {HTMLElement} item - Dropdown item element
1914
+ */
1915
+ applyDropdownItemStyles(item) {
1916
+ Object.assign(item.style, {
1917
+ padding: '8px 12px',
1918
+ cursor: 'pointer',
1919
+ color: this.options.textColor,
1920
+ transition: 'background-color 0.1s ease'
1921
+ });
1922
+
1923
+ item.addEventListener('mouseenter', () => {
1924
+ item.style.backgroundColor = '#f0f4f8';
1925
+ });
1926
+
1927
+ item.addEventListener('mouseleave', () => {
1928
+ item.style.backgroundColor = 'transparent';
1929
+ });
1930
+
1931
+ item.addEventListener('mousedown', () => {
1932
+ item.style.backgroundColor = '#e2e8f0';
1933
+ });
1934
+ }
1935
+
1936
+ /**
1937
+ * Apply user input styles
1938
+ * @param {HTMLElement} container - User input container
1939
+ * @param {HTMLElement} input - Input element
1940
+ */
1941
+ applyUserInputStyles(container, input) {
1942
+ // Container is now a tq-dropdown-item, so don't add padding here
1943
+ // Just set the border and background
1944
+ Object.assign(container.style, {
1945
+ backgroundColor: 'transparent',
1946
+ borderTop: `1px solid ${this.options.borderColor}`,
1947
+ // Override cursor from dropdown-item styles
1948
+ cursor: 'text'
1949
+ });
1950
+
1951
+ Object.assign(input.style, {
1952
+ width: '100%',
1953
+ padding: '8px 12px',
1954
+ border: 'none',
1955
+ borderBottom: '1px solid #ccc',
1956
+ borderRadius: '0',
1957
+ fontSize: '14px',
1958
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1959
+ color: this.options.textColor,
1960
+ backgroundColor: 'transparent',
1961
+ boxSizing: 'border-box',
1962
+ outline: 'none'
1963
+ });
1964
+
1965
+ input.addEventListener('focus', () => {
1966
+ input.style.borderBottom = '1px solid #ccc';
1967
+ });
1968
+
1969
+ input.addEventListener('blur', () => {
1970
+ input.style.borderBottom = '1px solid #ccc';
1971
+ });
1972
+ }
1973
+
1974
+ /**
1975
+ * Apply dropdown filter input styles
1976
+ * @param {HTMLElement} filterInput - Filter input element
1977
+ */
1978
+ applyDropdownFilterStyles(filterInput) {
1979
+ Object.assign(filterInput.style, {
1980
+ width: '100%',
1981
+ padding: '8px 12px',
1982
+ border: 'none',
1983
+ borderBottom: `1px solid ${this.options.borderColor}`,
1984
+ outline: 'none',
1985
+ fontSize: '14px',
1986
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1987
+ color: this.options.textColor,
1988
+ backgroundColor: '#f7fafc',
1989
+ boxSizing: 'border-box'
1990
+ });
1991
+
1992
+ // Focus styles
1993
+ filterInput.addEventListener('focus', () => {
1994
+ filterInput.style.backgroundColor = '#fff';
1995
+ filterInput.style.borderBottomColor = this.options.borderColorFocus;
1996
+ });
1997
+
1998
+ filterInput.addEventListener('blur', () => {
1999
+ filterInput.style.backgroundColor = '#f7fafc';
2000
+ filterInput.style.borderBottomColor = this.options.borderColor;
2001
+ });
2002
+ }
2003
+
2004
+ /**
2005
+ * Ensure animation keyframes are added to document (only once)
2006
+ */
2007
+ ensureAnimationStyles() {
2008
+ if (document.getElementById('tq-animations')) {
2009
+ return; // Already added
2010
+ }
2011
+
2012
+ const style = document.createElement('style');
2013
+ style.id = 'tq-animations';
2014
+ style.textContent = `
2015
+ @keyframes tq-bubble-appear {
2016
+ from {
2017
+ opacity: 0;
2018
+ transform: translateY(-4px);
2019
+ }
2020
+ to {
2021
+ opacity: 1;
2022
+ transform: translateY(0);
2023
+ }
2024
+ }
2025
+
2026
+ @keyframes tq-dropdown-appear {
2027
+ from {
2028
+ opacity: 0;
2029
+ transform: scale(0.95);
2030
+ }
2031
+ to {
2032
+ opacity: 1;
2033
+ transform: scale(1);
2034
+ }
2035
+ }
2036
+
2037
+ .tq-dropdown-item-selected {
2038
+ background-color: #e2e8f0 !important;
2039
+ }
2040
+ `;
2041
+ document.head.appendChild(style);
2042
+ }
2043
+
2044
+ /**
2045
+ * Ensure placeholder styles are added to document (only once)
2046
+ */
2047
+ ensurePlaceholderStyles() {
2048
+ if (document.getElementById('tq-placeholder-styles')) {
2049
+ return; // Already added
2050
+ }
2051
+
2052
+ const style = document.createElement('style');
2053
+ style.id = 'tq-placeholder-styles';
2054
+ style.textContent = `
2055
+ .tq-textarea::placeholder {
2056
+ color: #a0aec0 !important;
2057
+ opacity: 1 !important;
2058
+ -webkit-text-fill-color: #a0aec0 !important;
2059
+ }
2060
+
2061
+ .tq-textarea::-webkit-input-placeholder {
2062
+ color: #a0aec0 !important;
2063
+ opacity: 1 !important;
2064
+ -webkit-text-fill-color: #a0aec0 !important;
2065
+ }
2066
+
2067
+ .tq-textarea::-moz-placeholder {
2068
+ color: #a0aec0 !important;
2069
+ opacity: 1 !important;
2070
+ }
2071
+
2072
+ .tq-textarea:-ms-input-placeholder {
2073
+ color: #a0aec0 !important;
2074
+ opacity: 1 !important;
2075
+ }
2076
+ `;
2077
+ document.head.appendChild(style);
2078
+ }
2079
+
2080
+ /**
2081
+ * Update theme colors dynamically
2082
+ * @param {Object} newColors - New color options
2083
+ */
2084
+ updateTheme(newColors) {
2085
+ Object.assign(this.options, newColors);
2086
+ console.log('[StyleManager] Theme updated:', this.options);
2087
+ }
2088
+ }
2089
+
2090
+ // CommandHandlers - Handler registry for different command types
2091
+ // Each command type has specific styling and behavior
2092
+
2093
+ class CommandHandlerRegistry {
2094
+ constructor() {
2095
+ this.handlers = new Map();
2096
+ this.registerDefaultHandlers();
2097
+ }
2098
+
2099
+ /**
2100
+ * Register default handlers
2101
+ */
2102
+ registerDefaultHandlers() {
2103
+ this.register('not-allowed', new NotAllowedHandler());
2104
+ this.register('show-warning', new ShowWarningHandler());
2105
+ this.register('user-select-oneOf-and-warn', new UserSelectWithWarningHandler());
2106
+ this.register('user-select-oneOf', new UserSelectHandler());
2107
+ this.register('api-json-table', new ApiJsonTableHandler());
2108
+ this.register('api-md-table', new ApiMdTableHandler());
2109
+ }
2110
+
2111
+ /**
2112
+ * Register a handler
2113
+ * @param {string} commandType - Command type (e.g., 'not-allowed', 'show-warning')
2114
+ * @param {CommandHandler} handler - Handler instance
2115
+ */
2116
+ register(commandType, handler) {
2117
+ this.handlers.set(commandType, handler);
2118
+ console.log(`[CommandHandlerRegistry] Registered handler: ${commandType}`);
2119
+ }
2120
+
2121
+ /**
2122
+ * Get handler for a command type
2123
+ * @param {string} commandType - Command type
2124
+ * @returns {CommandHandler|null} Handler or null
2125
+ */
2126
+ getHandler(commandType) {
2127
+ return this.handlers.get(commandType) || null;
2128
+ }
2129
+
2130
+ /**
2131
+ * Get styles for a command type (or based on message-state)
2132
+ * @param {string} commandType - Command type
2133
+ * @param {Object} matchData - Match data including intent/handler
2134
+ * @returns {Object} Style configuration
2135
+ */
2136
+ getStyles(commandType, matchData = null) {
2137
+ // If matchData is provided, check message-state first
2138
+ if (matchData && matchData.intent && matchData.intent.handler) {
2139
+ const messageState = matchData.intent.handler['message-state'];
2140
+ if (messageState) {
2141
+ return this.getStylesForMessageState(messageState);
2142
+ }
2143
+ }
2144
+
2145
+ // Fall back to command type handler
2146
+ const handler = this.getHandler(commandType);
2147
+ return handler ? handler.getStyles() : this.getDefaultStyles();
2148
+ }
2149
+
2150
+ /**
2151
+ * Get styles based on message-state
2152
+ * @param {string} messageState - Message state (error, warning, info)
2153
+ * @returns {Object} Style configuration
2154
+ */
2155
+ getStylesForMessageState(messageState) {
2156
+ const stateStyles = {
2157
+ 'error': {
2158
+ backgroundColor: 'rgba(220, 38, 38, 0.15)', // Red
2159
+ color: '#991b1b',
2160
+ textDecoration: 'none',
2161
+ borderBottom: '2px solid #dc2626',
2162
+ borderRadius: '0',
2163
+ cursor: 'not-allowed'
2164
+ },
2165
+ 'warning': {
2166
+ backgroundColor: 'rgba(245, 158, 11, 0.15)', // Orange
2167
+ color: '#92400e',
2168
+ textDecoration: 'none',
2169
+ borderBottom: '2px solid #f59e0b',
2170
+ borderRadius: '0',
2171
+ cursor: 'help'
2172
+ },
2173
+ 'info': {
2174
+ backgroundColor: 'rgba(16, 185, 129, 0.15)', // Green
2175
+ color: '#065f46',
2176
+ textDecoration: 'none',
2177
+ borderBottom: '2px solid #10b981',
2178
+ borderRadius: '0',
2179
+ cursor: 'pointer'
2180
+ }
2181
+ };
2182
+
2183
+ return stateStyles[messageState] || this.getDefaultStyles();
2184
+ }
2185
+
2186
+ /**
2187
+ * Get bubble content for a match
2188
+ * @param {string} commandType - Command type
2189
+ * @param {Object} matchData - Match data including intent info
2190
+ * @returns {string} HTML content for bubble
2191
+ */
2192
+ getBubbleContent(commandType, matchData) {
2193
+ const handler = this.getHandler(commandType);
2194
+ return handler ? handler.getBubbleContent(matchData) : null;
2195
+ }
2196
+
2197
+ /**
2198
+ * Get default styles (fallback)
2199
+ */
2200
+ getDefaultStyles() {
2201
+ return {
2202
+ backgroundColor: 'rgba(74, 144, 226, 0.15)',
2203
+ color: '#2b6cb0',
2204
+ textDecoration: 'none',
2205
+ borderBottom: 'none'
2206
+ };
2207
+ }
2208
+ }
2209
+
2210
+ /**
2211
+ * Base handler class
2212
+ */
2213
+ class CommandHandler {
2214
+ getStyles() {
2215
+ return {};
2216
+ }
2217
+
2218
+ getBubbleContent(matchData) {
2219
+ return null;
2220
+ }
2221
+
2222
+ shouldBlockSubmit(matchData) {
2223
+ return false;
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * Handler for not-allowed commands (errors/forbidden)
2229
+ * Red background, red underline
2230
+ */
2231
+ class NotAllowedHandler extends CommandHandler {
2232
+ getStyles() {
2233
+ return {
2234
+ backgroundColor: 'rgba(220, 38, 38, 0.15)', // Red background
2235
+ color: '#991b1b', // Dark red text
2236
+ textDecoration: 'none',
2237
+ borderBottom: '2px solid #dc2626', // Red underline
2238
+ borderRadius: '0', // No radius to avoid curved bottom border
2239
+ cursor: 'not-allowed'
2240
+ };
2241
+ }
2242
+
2243
+ getBubbleContent(matchData) {
2244
+ const intent = matchData.intent || {};
2245
+ const handler = intent.handler || {};
2246
+
2247
+ const description = intent.description || 'Not allowed';
2248
+ const message = handler['message-content'] || description;
2249
+
2250
+ return `
2251
+ <div style="color: #991b1b;">
2252
+ <div style="font-weight: 600; margin-bottom: 4px; display: flex; align-items: center;">
2253
+ <span style="margin-right: 6px;">⛔</span>
2254
+ Not Allowed
2255
+ </div>
2256
+ <div style="font-size: 12px; line-height: 1.4;">
2257
+ ${this.escapeHtml(message)}
2258
+ </div>
2259
+ </div>
2260
+ `;
2261
+ }
2262
+
2263
+ shouldBlockSubmit(matchData) {
2264
+ const handler = matchData.intent?.handler || {};
2265
+ return handler['block-submit'] === true;
2266
+ }
2267
+
2268
+ escapeHtml(text) {
2269
+ const div = document.createElement('div');
2270
+ div.textContent = text;
2271
+ return div.innerHTML;
2272
+ }
2273
+ }
2274
+
2275
+ /**
2276
+ * Handler for show-warning commands
2277
+ * Yellow/orange background, orange underline
2278
+ */
2279
+ class ShowWarningHandler extends CommandHandler {
2280
+ getStyles() {
2281
+ return {
2282
+ backgroundColor: 'rgba(245, 158, 11, 0.15)', // Amber/orange background
2283
+ color: '#92400e', // Dark amber text
2284
+ textDecoration: 'none',
2285
+ borderBottom: '2px solid #f59e0b', // Orange underline
2286
+ borderRadius: '0', // No radius to avoid curved bottom border
2287
+ cursor: 'help'
2288
+ };
2289
+ }
2290
+
2291
+ getBubbleContent(matchData) {
2292
+ const intent = matchData.intent || {};
2293
+ const handler = intent.handler || {};
2294
+
2295
+ const description = intent.description || 'Warning';
2296
+ const message = handler['message-content'] || description;
2297
+
2298
+ return `
2299
+ <div style="color: #92400e;">
2300
+ <div style="font-weight: 600; margin-bottom: 4px; display: flex; align-items: center;">
2301
+ <span style="margin-right: 6px;">⚠️</span>
2302
+ Warning
2303
+ </div>
2304
+ <div style="font-size: 12px; line-height: 1.4;">
2305
+ ${this.escapeHtml(message)}
2306
+ </div>
2307
+ </div>
2308
+ `;
2309
+ }
2310
+
2311
+ shouldBlockSubmit(matchData) {
2312
+ const handler = matchData.intent?.handler || {};
2313
+ return handler['block-submit'] === true;
2314
+ }
2315
+
2316
+ escapeHtml(text) {
2317
+ const div = document.createElement('div');
2318
+ div.textContent = text;
2319
+ return div.innerHTML;
2320
+ }
2321
+ }
2322
+
2323
+ /**
2324
+ * Handler for user-select-oneOf-and-warn commands
2325
+ * Shows warning + dropdown
2326
+ */
2327
+ class UserSelectWithWarningHandler extends CommandHandler {
2328
+ getStyles() {
2329
+ return {
2330
+ backgroundColor: 'rgba(245, 158, 11, 0.15)',
2331
+ color: '#92400e',
2332
+ textDecoration: 'none',
2333
+ borderBottom: '2px solid #f59e0b',
2334
+ cursor: 'pointer'
2335
+ };
2336
+ }
2337
+
2338
+ getBubbleContent(matchData) {
2339
+ // Will show dropdown on click instead of bubble
2340
+ return null;
2341
+ }
2342
+ }
2343
+
2344
+ /**
2345
+ * Handler for user-select-oneOf commands
2346
+ * Shows dropdown without warning
2347
+ */
2348
+ class UserSelectHandler extends CommandHandler {
2349
+ getStyles() {
2350
+ return {
2351
+ backgroundColor: 'rgba(59, 130, 246, 0.15)', // Blue background
2352
+ color: '#1e40af', // Dark blue text
2353
+ textDecoration: 'none',
2354
+ borderBottom: '2px solid #3b82f6', // Blue underline
2355
+ cursor: 'pointer'
2356
+ };
2357
+ }
2358
+
2359
+ getBubbleContent(matchData) {
2360
+ // Will show dropdown on click instead of bubble
2361
+ return null;
2362
+ }
2363
+ }
2364
+
2365
+ /**
2366
+ * Handler for api-json-table commands
2367
+ */
2368
+ class ApiJsonTableHandler extends CommandHandler {
2369
+ getStyles() {
2370
+ return {
2371
+ backgroundColor: 'rgba(16, 185, 129, 0.15)', // Green background
2372
+ color: '#065f46', // Dark green text
2373
+ textDecoration: 'none',
2374
+ borderBottom: '2px solid #10b981',
2375
+ cursor: 'pointer'
2376
+ };
2377
+ }
2378
+
2379
+ getBubbleContent(matchData) {
2380
+ const intent = matchData.intent || {};
2381
+ const description = intent.description || 'Click to view data';
2382
+
2383
+ return `
2384
+ <div style="color: #065f46;">
2385
+ <div style="font-weight: 600; margin-bottom: 4px;">
2386
+ 📊 Data Table
2387
+ </div>
2388
+ <div style="font-size: 12px;">
2389
+ ${this.escapeHtml(description)}
2390
+ </div>
2391
+ </div>
2392
+ `;
2393
+ }
2394
+
2395
+ escapeHtml(text) {
2396
+ const div = document.createElement('div');
2397
+ div.textContent = text;
2398
+ return div.innerHTML;
2399
+ }
2400
+ }
2401
+
2402
+ /**
2403
+ * Handler for api-md-table commands
2404
+ */
2405
+ class ApiMdTableHandler extends CommandHandler {
2406
+ getStyles() {
2407
+ return {
2408
+ backgroundColor: 'rgba(16, 185, 129, 0.15)',
2409
+ color: '#065f46',
2410
+ textDecoration: 'none',
2411
+ borderBottom: '2px solid #10b981',
2412
+ cursor: 'pointer'
2413
+ };
2414
+ }
2415
+
2416
+ getBubbleContent(matchData) {
2417
+ const intent = matchData.intent || {};
2418
+ const description = intent.description || 'Click to view data';
2419
+
2420
+ return `
2421
+ <div style="color: #065f46;">
2422
+ <div style="font-weight: 600; margin-bottom: 4px;">
2423
+ 📊 Data Table
2424
+ </div>
2425
+ <div style="font-size: 12px;">
2426
+ ${this.escapeHtml(description)}
2427
+ </div>
2428
+ </div>
2429
+ `;
2430
+ }
2431
+
2432
+ escapeHtml(text) {
2433
+ const div = document.createElement('div');
2434
+ div.textContent = text;
2435
+ return div.innerHTML;
2436
+ }
2437
+ }
2438
+
2439
+ // AutoGrow - Automatically grows textarea height based on content
2440
+
2441
+ class AutoGrow {
2442
+ /**
2443
+ * Create auto-grow feature
2444
+ * @param {HTMLElement} textarea - Textarea element
2445
+ * @param {Object} options - Configuration
2446
+ */
2447
+ constructor(textarea, options = {}) {
2448
+ this.textarea = textarea;
2449
+ this.options = {
2450
+ maxHeight: options.maxHeight || 300,
2451
+ minHeight: options.minHeight || 44,
2452
+ ...options
2453
+ };
2454
+
2455
+ this.initialize();
2456
+ console.log('[AutoGrow] Initialized with max height:', this.options.maxHeight);
2457
+ }
2458
+
2459
+ /**
2460
+ * Initialize auto-grow functionality
2461
+ */
2462
+ initialize() {
2463
+ // Set initial min-height
2464
+ this.textarea.style.minHeight = `${this.options.minHeight}px`;
2465
+
2466
+ // Listen for input changes
2467
+ this.textarea.addEventListener('input', () => this.adjust());
2468
+
2469
+ // Listen for keyboard events (for line breaks)
2470
+ this.textarea.addEventListener('keydown', (e) => {
2471
+ if (e.key === 'Enter' && !e.shiftKey) {
2472
+ setTimeout(() => this.adjust(), 0);
2473
+ }
2474
+ });
2475
+
2476
+ // Initial adjustment
2477
+ this.adjust();
2478
+ }
2479
+
2480
+ /**
2481
+ * Adjust textarea height based on content
2482
+ */
2483
+ adjust() {
2484
+ // Reset height to calculate new height
2485
+ this.textarea.style.height = 'auto';
2486
+
2487
+ // Calculate new height (respecting max height)
2488
+ const newHeight = Math.min(
2489
+ Math.max(this.textarea.scrollHeight, this.options.minHeight),
2490
+ this.options.maxHeight
2491
+ );
2492
+
2493
+ this.textarea.style.height = newHeight + 'px';
2494
+ }
2495
+
2496
+ /**
2497
+ * Destroy auto-grow (remove listeners and reset styles)
2498
+ */
2499
+ destroy() {
2500
+ this.textarea.style.height = '';
2501
+ this.textarea.style.minHeight = '';
2502
+ console.log('[AutoGrow] Destroyed');
2503
+ }
2504
+ }
2505
+
2506
+ // TrustQuery - Lightweight library to make textareas interactive
2507
+ // Turns matching words into interactive elements with hover bubbles and click actions
2508
+
2509
+
2510
+ class TrustQuery {
2511
+ // Store all instances
2512
+ static instances = new Map();
2513
+
2514
+ /**
2515
+ * Initialize TrustQuery on a textarea
2516
+ * @param {string|HTMLElement} textareaId - ID or element of textarea
2517
+ * @param {Object} options - Configuration options
2518
+ * @returns {TrustQuery} Instance
2519
+ */
2520
+ static init(textareaId, options = {}) {
2521
+ const textarea = typeof textareaId === 'string'
2522
+ ? document.getElementById(textareaId)
2523
+ : textareaId;
2524
+
2525
+ if (!textarea) {
2526
+ console.error('[TrustQuery] Textarea not found:', textareaId);
2527
+ return null;
2528
+ }
2529
+
2530
+ // Check if already initialized
2531
+ const existingInstance = TrustQuery.instances.get(textarea);
2532
+ if (existingInstance) {
2533
+ console.warn('[TrustQuery] Already initialized on this textarea, returning existing instance');
2534
+ return existingInstance;
2535
+ }
2536
+
2537
+ // Create new instance
2538
+ const instance = new TrustQuery(textarea, options);
2539
+ TrustQuery.instances.set(textarea, instance);
2540
+
2541
+ console.log('[TrustQuery] Initialized successfully on:', textarea.id || textarea);
2542
+ return instance;
2543
+ }
2544
+
2545
+ /**
2546
+ * Get existing instance
2547
+ * @param {string|HTMLElement} textareaId - ID or element
2548
+ * @returns {TrustQuery|null} Instance or null
2549
+ */
2550
+ static getInstance(textareaId) {
2551
+ const textarea = typeof textareaId === 'string'
2552
+ ? document.getElementById(textareaId)
2553
+ : textareaId;
2554
+ return TrustQuery.instances.get(textarea) || null;
2555
+ }
2556
+
2557
+ /**
2558
+ * Create a TrustQuery instance
2559
+ * @param {HTMLElement} textarea - Textarea element
2560
+ * @param {Object} options - Configuration
2561
+ */
2562
+ constructor(textarea, options = {}) {
2563
+ this.textarea = textarea;
2564
+
2565
+ // Normalize options (support both old and new API)
2566
+ this.options = this.normalizeOptions(options);
2567
+
2568
+ this.commandMap = null;
2569
+ this.isReady = false;
2570
+ this.features = {};
2571
+
2572
+ // Initialize components
2573
+ this.init();
2574
+ }
2575
+
2576
+ /**
2577
+ * Normalize options - support both old and new API
2578
+ * @param {Object} options - Raw options
2579
+ * @returns {Object} Normalized options
2580
+ */
2581
+ normalizeOptions(options) {
2582
+ // New API structure
2583
+ const triggerMap = options.triggerMap || {};
2584
+ const features = options.features || {};
2585
+ const ui = options.ui || {};
2586
+ const events = options.events || {};
2587
+
2588
+ // Build normalized config (backward compatible)
2589
+ return {
2590
+ // Trigger map configuration
2591
+ commandMapUrl: triggerMap.url || options.commandMapUrl || null,
2592
+ commandMap: triggerMap.data || options.commandMap || null,
2593
+ autoLoadCommandMap: options.autoLoadCommandMap !== false,
2594
+ triggerMapSource: triggerMap.source || (triggerMap.url ? 'url' : triggerMap.data ? 'inline' : null),
2595
+ triggerMapApi: triggerMap.api || null,
2596
+
2597
+ // Features
2598
+ autoGrow: features.autoGrow || false,
2599
+ autoGrowMaxHeight: features.maxHeight || 300,
2600
+ debug: features.debug || false,
2601
+
2602
+ // UI settings
2603
+ theme: ui.theme || options.theme || 'light',
2604
+ bubbleDelay: ui.bubbleDelay !== undefined ? ui.bubbleDelay : (options.bubbleDelay || 200),
2605
+ dropdownOffset: ui.dropdownOffset !== undefined ? ui.dropdownOffset : (options.dropdownOffset || 28),
2606
+
2607
+ // Events (callbacks)
2608
+ onWordClick: events.onWordClick || options.onWordClick || null,
2609
+ onWordHover: events.onWordHover || options.onWordHover || null,
2610
+
2611
+ // Theme/style options (passed to StyleManager)
2612
+ backgroundColor: ui.backgroundColor || options.backgroundColor,
2613
+ textColor: ui.textColor || options.textColor,
2614
+ caretColor: ui.caretColor || options.caretColor,
2615
+ borderColor: ui.borderColor || options.borderColor,
2616
+ borderColorFocus: ui.borderColorFocus || options.borderColorFocus,
2617
+ matchBackgroundColor: ui.matchBackgroundColor || options.matchBackgroundColor,
2618
+ matchTextColor: ui.matchTextColor || options.matchTextColor,
2619
+ matchHoverBackgroundColor: ui.matchHoverBackgroundColor || options.matchHoverBackgroundColor,
2620
+ fontFamily: ui.fontFamily || options.fontFamily,
2621
+ fontSize: ui.fontSize || options.fontSize,
2622
+ lineHeight: ui.lineHeight || options.lineHeight,
2623
+
2624
+ ...options
2625
+ };
2626
+ }
2627
+
2628
+ /**
2629
+ * Initialize all components
2630
+ */
2631
+ async init() {
2632
+ console.log('[TrustQuery] Starting initialization...');
2633
+
2634
+ // Initialize command handler registry
2635
+ this.commandHandlers = new CommandHandlerRegistry();
2636
+
2637
+ // Initialize style manager (handles all inline styling)
2638
+ this.styleManager = new StyleManager(this.options);
2639
+
2640
+ // Create wrapper and overlay structure
2641
+ this.createOverlayStructure();
2642
+
2643
+ // Initialize renderer
2644
+ this.renderer = new OverlayRenderer(this.overlay, {
2645
+ theme: this.options.theme,
2646
+ commandHandlers: this.commandHandlers // Pass handlers for styling
2647
+ });
2648
+
2649
+ // Initialize scanner (will be configured when command map loads)
2650
+ this.scanner = new CommandScanner();
2651
+
2652
+ // Initialize interaction handler
2653
+ this.interactionHandler = new InteractionHandler(this.overlay, {
2654
+ bubbleDelay: this.options.bubbleDelay,
2655
+ dropdownOffset: this.options.dropdownOffset,
2656
+ onWordClick: this.options.onWordClick,
2657
+ onWordHover: this.options.onWordHover,
2658
+ styleManager: this.styleManager, // Pass style manager for bubbles/dropdowns
2659
+ commandHandlers: this.commandHandlers, // Pass handlers for bubble content
2660
+ textarea: this.textarea // Pass textarea for on-select display updates
2661
+ });
2662
+
2663
+ // Initialize features
2664
+ this.initializeFeatures();
2665
+
2666
+ // Setup textarea event listeners
2667
+ this.setupTextareaListeners();
2668
+
2669
+ // Load command map
2670
+ if (this.options.autoLoadCommandMap) {
2671
+ await this.loadCommandMap();
2672
+ } else if (this.options.commandMap) {
2673
+ this.updateCommandMap(this.options.commandMap);
2674
+ }
2675
+
2676
+ // Initial render
2677
+ this.render();
2678
+
2679
+ this.isReady = true;
2680
+
2681
+ // Auto-focus textarea to show cursor
2682
+ setTimeout(() => {
2683
+ this.textarea.focus();
2684
+ }, 100);
2685
+
2686
+ console.log('[TrustQuery] Initialization complete');
2687
+ }
2688
+
2689
+ /**
2690
+ * Initialize optional features based on configuration
2691
+ */
2692
+ initializeFeatures() {
2693
+ // Auto-grow textarea feature
2694
+ if (this.options.autoGrow) {
2695
+ this.features.autoGrow = new AutoGrow(this.textarea, {
2696
+ maxHeight: this.options.autoGrowMaxHeight,
2697
+ minHeight: 44
2698
+ });
2699
+ console.log('[TrustQuery] AutoGrow feature enabled');
2700
+ }
2701
+
2702
+ // Debug logging feature
2703
+ if (this.options.debug) {
2704
+ this.enableDebugLogging();
2705
+ console.log('[TrustQuery] Debug logging enabled');
2706
+ }
2707
+ }
2708
+
2709
+ /**
2710
+ * Enable debug logging for events
2711
+ */
2712
+ enableDebugLogging() {
2713
+ const originalOnWordHover = this.options.onWordHover;
2714
+ const originalOnWordClick = this.options.onWordClick;
2715
+
2716
+ this.options.onWordHover = (matchData) => {
2717
+ console.log('[TrustQuery Debug] Word Hover:', matchData);
2718
+ if (originalOnWordHover) originalOnWordHover(matchData);
2719
+ };
2720
+
2721
+ this.options.onWordClick = (matchData) => {
2722
+ console.log('[TrustQuery Debug] Word Click:', matchData);
2723
+ if (originalOnWordClick) originalOnWordClick(matchData);
2724
+ };
2725
+ }
2726
+
2727
+ /**
2728
+ * Create the overlay structure
2729
+ */
2730
+ createOverlayStructure() {
2731
+ // Create wrapper to contain both textarea and overlay
2732
+ const wrapper = document.createElement('div');
2733
+ wrapper.className = 'tq-wrapper';
2734
+
2735
+ // Wrap textarea
2736
+ this.textarea.parentNode.insertBefore(wrapper, this.textarea);
2737
+ wrapper.appendChild(this.textarea);
2738
+
2739
+ // Add TrustQuery class to textarea
2740
+ this.textarea.classList.add('tq-textarea');
2741
+
2742
+ // Create overlay
2743
+ this.overlay = document.createElement('div');
2744
+ this.overlay.className = 'tq-overlay';
2745
+ wrapper.appendChild(this.overlay);
2746
+
2747
+ // Store wrapper reference
2748
+ this.wrapper = wrapper;
2749
+
2750
+ // Apply all inline styles via StyleManager
2751
+ this.styleManager.applyAllStyles(wrapper, this.textarea, this.overlay);
2752
+
2753
+ // Show textarea now that structure is ready (prevents FOUC)
2754
+ this.textarea.style.opacity = '1';
2755
+
2756
+ console.log('[TrustQuery] Overlay structure created with inline styles');
2757
+ }
2758
+
2759
+ /**
2760
+ * Setup textarea event listeners
2761
+ */
2762
+ setupTextareaListeners() {
2763
+ // Input event - re-render on content change
2764
+ this.textarea.addEventListener('input', () => {
2765
+ this.render();
2766
+ });
2767
+
2768
+ // Scroll event - sync overlay scroll with textarea
2769
+ this.textarea.addEventListener('scroll', () => {
2770
+ this.overlay.scrollTop = this.textarea.scrollTop;
2771
+ this.overlay.scrollLeft = this.textarea.scrollLeft;
2772
+ });
2773
+
2774
+ // Focus/blur events - add/remove focus class
2775
+ this.textarea.addEventListener('focus', () => {
2776
+ this.wrapper.classList.add('tq-focused');
2777
+ });
2778
+
2779
+ this.textarea.addEventListener('blur', (e) => {
2780
+ // Close dropdown when textarea loses focus (unless interacting with dropdown)
2781
+ if (this.interactionHandler) {
2782
+ // Use setTimeout to let the new focus target be set and check if clicking on dropdown
2783
+ setTimeout(() => {
2784
+ const activeElement = document.activeElement;
2785
+ const isDropdownRelated = activeElement && (
2786
+ activeElement.classList.contains('tq-dropdown-filter') ||
2787
+ activeElement.closest('.tq-dropdown') // Check if clicking anywhere in dropdown
2788
+ );
2789
+
2790
+ // Only close if not interacting with dropdown
2791
+ if (!isDropdownRelated) {
2792
+ this.interactionHandler.hideDropdown();
2793
+ }
2794
+
2795
+ // Remove focus class only if we're truly leaving the component
2796
+ if (!isDropdownRelated) {
2797
+ this.wrapper.classList.remove('tq-focused');
2798
+ }
2799
+ }, 0);
2800
+ }
2801
+ });
2802
+
2803
+ console.log('[TrustQuery] Textarea listeners attached');
2804
+ }
2805
+
2806
+ /**
2807
+ * Load command map (static tql-triggers.json or from URL)
2808
+ */
2809
+ async loadCommandMap() {
2810
+ try {
2811
+ // Default to static file if no URL provided
2812
+ const url = this.options.commandMapUrl || '/trustquery/tql-triggers.json';
2813
+
2814
+ console.log('[TrustQuery] Loading trigger map from:', url);
2815
+ const response = await fetch(url);
2816
+
2817
+ if (!response.ok) {
2818
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2819
+ }
2820
+
2821
+ const data = await response.json();
2822
+ this.updateCommandMap(data);
2823
+
2824
+ console.log('[TrustQuery] Trigger map loaded successfully');
2825
+ } catch (error) {
2826
+ console.error('[TrustQuery] Failed to load trigger map:', error);
2827
+ }
2828
+ }
2829
+
2830
+ /**
2831
+ * Update command map
2832
+ * @param {Object} commandMap - New command map
2833
+ */
2834
+ updateCommandMap(commandMap) {
2835
+ this.commandMap = commandMap;
2836
+ this.scanner.setCommandMap(commandMap);
2837
+ console.log('[TrustQuery] Command map updated');
2838
+
2839
+ // Re-render with new command map
2840
+ if (this.isReady) {
2841
+ this.render();
2842
+ }
2843
+ }
2844
+
2845
+ /**
2846
+ * Render the overlay with styled text
2847
+ */
2848
+ render() {
2849
+ const text = this.textarea.value;
2850
+
2851
+ // Scan text for matches
2852
+ const matches = this.scanner.scan(text);
2853
+
2854
+ // Render overlay with matches
2855
+ this.renderer.render(text, matches);
2856
+
2857
+ // Update interaction handler with new elements
2858
+ this.interactionHandler.update();
2859
+ }
2860
+
2861
+ /**
2862
+ * Destroy instance and cleanup
2863
+ */
2864
+ destroy() {
2865
+ console.log('[TrustQuery] Destroying instance');
2866
+
2867
+ // Remove event listeners
2868
+ this.textarea.removeEventListener('input', this.render);
2869
+ this.textarea.removeEventListener('scroll', this.syncScroll);
2870
+
2871
+ // Cleanup interaction handler
2872
+ if (this.interactionHandler) {
2873
+ this.interactionHandler.destroy();
2874
+ }
2875
+
2876
+ // Unwrap textarea
2877
+ const parent = this.wrapper.parentNode;
2878
+ parent.insertBefore(this.textarea, this.wrapper);
2879
+ parent.removeChild(this.wrapper);
2880
+
2881
+ // Remove from instances
2882
+ TrustQuery.instances.delete(this.textarea);
2883
+
2884
+ console.log('[TrustQuery] Destroyed');
2885
+ }
2886
+
2887
+ /**
2888
+ * Get current value
2889
+ */
2890
+ getValue() {
2891
+ return this.textarea.value;
2892
+ }
2893
+
2894
+ /**
2895
+ * Set value
2896
+ */
2897
+ setValue(value) {
2898
+ this.textarea.value = value;
2899
+ this.render();
2900
+ }
2901
+ }
2902
+
2903
+ export { TrustQuery as default };
2904
+ //# sourceMappingURL=trustquery.js.map