@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,225 @@
1
+ // InteractionHandler - Orchestrates hover bubbles and click interactions on matched words
2
+ // Delegates to BubbleManager and DropdownManager for specific functionality
3
+
4
+ import BubbleManager from './BubbleManager.js';
5
+ import DropdownManager from './DropdownManager.js';
6
+
7
+ export default class InteractionHandler {
8
+ /**
9
+ * Create interaction handler
10
+ * @param {HTMLElement} overlay - Overlay element containing matches
11
+ * @param {Object} options - Configuration
12
+ */
13
+ constructor(overlay, options = {}) {
14
+ this.overlay = overlay;
15
+ this.options = {
16
+ bubbleDelay: options.bubbleDelay || 200,
17
+ onWordClick: options.onWordClick || null,
18
+ onWordHover: options.onWordHover || null,
19
+ styleManager: options.styleManager || null,
20
+ commandHandlers: options.commandHandlers || null,
21
+ textarea: options.textarea || null,
22
+ ...options
23
+ };
24
+
25
+ this.hoverTimeout = null;
26
+
27
+ // Create manager instances
28
+ this.bubbleManager = new BubbleManager({
29
+ bubbleDelay: this.options.bubbleDelay,
30
+ styleManager: this.options.styleManager,
31
+ commandHandlers: this.options.commandHandlers
32
+ });
33
+
34
+ this.dropdownManager = new DropdownManager({
35
+ styleManager: this.options.styleManager,
36
+ textarea: this.options.textarea,
37
+ onWordClick: this.options.onWordClick,
38
+ dropdownOffset: this.options.dropdownOffset
39
+ });
40
+
41
+ console.log('[InteractionHandler] Initialized');
42
+ }
43
+
44
+ /**
45
+ * Update handlers after overlay re-render
46
+ * Attach event listeners to all .tq-match elements
47
+ */
48
+ update() {
49
+ // Remove old handlers (if any)
50
+ this.cleanup();
51
+
52
+ // Find all match elements
53
+ const matches = this.overlay.querySelectorAll('.tq-match');
54
+
55
+ matches.forEach(matchEl => {
56
+ const behavior = matchEl.getAttribute('data-behavior');
57
+
58
+ // Hover events for bubbles
59
+ matchEl.addEventListener('mouseenter', (e) => this.handleMouseEnter(e, matchEl));
60
+ matchEl.addEventListener('mouseleave', (e) => this.handleMouseLeave(e, matchEl));
61
+
62
+ // Click events for dropdowns/actions
63
+ matchEl.addEventListener('click', (e) => this.handleClick(e, matchEl));
64
+
65
+ // Add hover class for CSS styling
66
+ matchEl.classList.add('tq-hoverable');
67
+
68
+ // Auto-show dropdown for dropdown-behavior matches
69
+ if (behavior === 'dropdown') {
70
+ const matchData = this.getMatchData(matchEl);
71
+ // Only show if this isn't the currently active dropdown match
72
+ if (!this.dropdownManager.activeDropdownMatch ||
73
+ this.dropdownManager.activeDropdownMatch.textContent !== matchEl.textContent) {
74
+ this.dropdownManager.showDropdown(matchEl, matchData);
75
+ }
76
+ }
77
+ });
78
+
79
+ console.log('[InteractionHandler] Updated with', matches.length, 'interactive elements');
80
+ }
81
+
82
+ /**
83
+ * Handle mouse enter on a match
84
+ * @param {Event} e - Mouse event
85
+ * @param {HTMLElement} matchEl - Match element
86
+ */
87
+ handleMouseEnter(e, matchEl) {
88
+ const behavior = matchEl.getAttribute('data-behavior');
89
+
90
+ // Only show bubble if behavior is 'bubble' or 'hover'
91
+ if (behavior === 'bubble' || behavior === 'hover') {
92
+ // Clear any existing timeout
93
+ if (this.hoverTimeout) {
94
+ clearTimeout(this.hoverTimeout);
95
+ }
96
+
97
+ // Delay bubble appearance
98
+ this.hoverTimeout = setTimeout(() => {
99
+ const matchData = this.getMatchData(matchEl);
100
+ this.bubbleManager.showBubble(matchEl, matchData);
101
+ }, this.options.bubbleDelay);
102
+ }
103
+
104
+ // Callback
105
+ if (this.options.onWordHover) {
106
+ const matchData = this.getMatchData(matchEl);
107
+ this.options.onWordHover(matchData);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Handle mouse leave from a match
113
+ * @param {Event} e - Mouse event
114
+ * @param {HTMLElement} matchEl - Match element
115
+ */
116
+ handleMouseLeave(e, matchEl) {
117
+ // Clear hover timeout
118
+ if (this.hoverTimeout) {
119
+ clearTimeout(this.hoverTimeout);
120
+ this.hoverTimeout = null;
121
+ }
122
+
123
+ // Don't immediately hide bubble - let user hover over it
124
+ // Bubble will auto-hide when mouse leaves bubble area
125
+ }
126
+
127
+ /**
128
+ * Handle click on a match
129
+ * @param {Event} e - Click event
130
+ * @param {HTMLElement} matchEl - Match element
131
+ */
132
+ handleClick(e, matchEl) {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+
136
+ const behavior = matchEl.getAttribute('data-behavior');
137
+ const matchData = this.getMatchData(matchEl);
138
+
139
+ console.log('[InteractionHandler] Match clicked:', matchData);
140
+
141
+ // Handle different behaviors
142
+ if (behavior === 'dropdown') {
143
+ // Toggle dropdown - close if already open for this match, otherwise show
144
+ if (this.dropdownManager.activeDropdownMatch === matchEl) {
145
+ this.dropdownManager.hideDropdown();
146
+ } else {
147
+ this.dropdownManager.showDropdown(matchEl, matchData);
148
+ }
149
+ } else if (behavior === 'action') {
150
+ // Custom action callback
151
+ if (this.options.onWordClick) {
152
+ this.options.onWordClick(matchData);
153
+ }
154
+ }
155
+
156
+ // Always trigger callback (unless it's a dropdown toggle to close)
157
+ if (this.options.onWordClick && !(behavior === 'dropdown' && this.dropdownManager.activeDropdownMatch === matchEl)) {
158
+ this.options.onWordClick(matchData);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Hide dropdown (exposed for external use, e.g., when textarea loses focus)
164
+ */
165
+ hideDropdown() {
166
+ this.dropdownManager.hideDropdown();
167
+ }
168
+
169
+ /**
170
+ * Extract match data from element
171
+ * @param {HTMLElement} matchEl - Match element
172
+ * @returns {Object} Match data
173
+ */
174
+ getMatchData(matchEl) {
175
+ // Parse intent JSON if available
176
+ let intent = null;
177
+ const intentStr = matchEl.getAttribute('data-intent');
178
+ if (intentStr) {
179
+ try {
180
+ intent = JSON.parse(intentStr);
181
+ } catch (e) {
182
+ console.warn('[InteractionHandler] Failed to parse intent JSON:', e);
183
+ }
184
+ }
185
+
186
+ return {
187
+ text: matchEl.getAttribute('data-match-text'),
188
+ line: parseInt(matchEl.getAttribute('data-line')),
189
+ col: parseInt(matchEl.getAttribute('data-col')),
190
+ commandType: matchEl.getAttribute('data-command-type'),
191
+ commandPath: matchEl.getAttribute('data-command-path'),
192
+ intentPath: matchEl.getAttribute('data-intent-path'),
193
+ intent: intent,
194
+ command: {
195
+ id: matchEl.getAttribute('data-command-id'),
196
+ type: matchEl.getAttribute('data-command-type'),
197
+ behavior: matchEl.getAttribute('data-behavior')
198
+ },
199
+ element: matchEl
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Cleanup event listeners
205
+ */
206
+ cleanup() {
207
+ this.bubbleManager.cleanup();
208
+ this.dropdownManager.cleanup();
209
+
210
+ if (this.hoverTimeout) {
211
+ clearTimeout(this.hoverTimeout);
212
+ this.hoverTimeout = null;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Destroy handler
218
+ */
219
+ destroy() {
220
+ this.cleanup();
221
+ this.bubbleManager.destroy();
222
+ this.dropdownManager.destroy();
223
+ console.log('[InteractionHandler] Destroyed');
224
+ }
225
+ }
@@ -0,0 +1,241 @@
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
+ export default 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
+ }