@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,402 @@
1
+ // TrustQuery - Lightweight library to make textareas interactive
2
+ // Turns matching words into interactive elements with hover bubbles and click actions
3
+
4
+ import OverlayRenderer from './OverlayRenderer.js';
5
+ import CommandScanner from './CommandScanner.js';
6
+ import InteractionHandler from './InteractionHandler.js';
7
+ import StyleManager from './StyleManager.js';
8
+ import CommandHandlerRegistry from './CommandHandlers.js';
9
+ import AutoGrow from './AutoGrow.js';
10
+
11
+ export default class TrustQuery {
12
+ // Store all instances
13
+ static instances = new Map();
14
+
15
+ /**
16
+ * Initialize TrustQuery on a textarea
17
+ * @param {string|HTMLElement} textareaId - ID or element of textarea
18
+ * @param {Object} options - Configuration options
19
+ * @returns {TrustQuery} Instance
20
+ */
21
+ static init(textareaId, options = {}) {
22
+ const textarea = typeof textareaId === 'string'
23
+ ? document.getElementById(textareaId)
24
+ : textareaId;
25
+
26
+ if (!textarea) {
27
+ console.error('[TrustQuery] Textarea not found:', textareaId);
28
+ return null;
29
+ }
30
+
31
+ // Check if already initialized
32
+ const existingInstance = TrustQuery.instances.get(textarea);
33
+ if (existingInstance) {
34
+ console.warn('[TrustQuery] Already initialized on this textarea, returning existing instance');
35
+ return existingInstance;
36
+ }
37
+
38
+ // Create new instance
39
+ const instance = new TrustQuery(textarea, options);
40
+ TrustQuery.instances.set(textarea, instance);
41
+
42
+ console.log('[TrustQuery] Initialized successfully on:', textarea.id || textarea);
43
+ return instance;
44
+ }
45
+
46
+ /**
47
+ * Get existing instance
48
+ * @param {string|HTMLElement} textareaId - ID or element
49
+ * @returns {TrustQuery|null} Instance or null
50
+ */
51
+ static getInstance(textareaId) {
52
+ const textarea = typeof textareaId === 'string'
53
+ ? document.getElementById(textareaId)
54
+ : textareaId;
55
+ return TrustQuery.instances.get(textarea) || null;
56
+ }
57
+
58
+ /**
59
+ * Create a TrustQuery instance
60
+ * @param {HTMLElement} textarea - Textarea element
61
+ * @param {Object} options - Configuration
62
+ */
63
+ constructor(textarea, options = {}) {
64
+ this.textarea = textarea;
65
+
66
+ // Normalize options (support both old and new API)
67
+ this.options = this.normalizeOptions(options);
68
+
69
+ this.commandMap = null;
70
+ this.isReady = false;
71
+ this.features = {};
72
+
73
+ // Initialize components
74
+ this.init();
75
+ }
76
+
77
+ /**
78
+ * Normalize options - support both old and new API
79
+ * @param {Object} options - Raw options
80
+ * @returns {Object} Normalized options
81
+ */
82
+ normalizeOptions(options) {
83
+ // New API structure
84
+ const triggerMap = options.triggerMap || {};
85
+ const features = options.features || {};
86
+ const ui = options.ui || {};
87
+ const events = options.events || {};
88
+
89
+ // Build normalized config (backward compatible)
90
+ return {
91
+ // Trigger map configuration
92
+ commandMapUrl: triggerMap.url || options.commandMapUrl || null,
93
+ commandMap: triggerMap.data || options.commandMap || null,
94
+ autoLoadCommandMap: options.autoLoadCommandMap !== false,
95
+ triggerMapSource: triggerMap.source || (triggerMap.url ? 'url' : triggerMap.data ? 'inline' : null),
96
+ triggerMapApi: triggerMap.api || null,
97
+
98
+ // Features
99
+ autoGrow: features.autoGrow || false,
100
+ autoGrowMaxHeight: features.maxHeight || 300,
101
+ debug: features.debug || false,
102
+
103
+ // UI settings
104
+ theme: ui.theme || options.theme || 'light',
105
+ bubbleDelay: ui.bubbleDelay !== undefined ? ui.bubbleDelay : (options.bubbleDelay || 200),
106
+ dropdownOffset: ui.dropdownOffset !== undefined ? ui.dropdownOffset : (options.dropdownOffset || 28),
107
+
108
+ // Events (callbacks)
109
+ onWordClick: events.onWordClick || options.onWordClick || null,
110
+ onWordHover: events.onWordHover || options.onWordHover || null,
111
+
112
+ // Theme/style options (passed to StyleManager)
113
+ backgroundColor: ui.backgroundColor || options.backgroundColor,
114
+ textColor: ui.textColor || options.textColor,
115
+ caretColor: ui.caretColor || options.caretColor,
116
+ borderColor: ui.borderColor || options.borderColor,
117
+ borderColorFocus: ui.borderColorFocus || options.borderColorFocus,
118
+ matchBackgroundColor: ui.matchBackgroundColor || options.matchBackgroundColor,
119
+ matchTextColor: ui.matchTextColor || options.matchTextColor,
120
+ matchHoverBackgroundColor: ui.matchHoverBackgroundColor || options.matchHoverBackgroundColor,
121
+ fontFamily: ui.fontFamily || options.fontFamily,
122
+ fontSize: ui.fontSize || options.fontSize,
123
+ lineHeight: ui.lineHeight || options.lineHeight,
124
+
125
+ ...options
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Initialize all components
131
+ */
132
+ async init() {
133
+ console.log('[TrustQuery] Starting initialization...');
134
+
135
+ // Initialize command handler registry
136
+ this.commandHandlers = new CommandHandlerRegistry();
137
+
138
+ // Initialize style manager (handles all inline styling)
139
+ this.styleManager = new StyleManager(this.options);
140
+
141
+ // Create wrapper and overlay structure
142
+ this.createOverlayStructure();
143
+
144
+ // Initialize renderer
145
+ this.renderer = new OverlayRenderer(this.overlay, {
146
+ theme: this.options.theme,
147
+ commandHandlers: this.commandHandlers // Pass handlers for styling
148
+ });
149
+
150
+ // Initialize scanner (will be configured when command map loads)
151
+ this.scanner = new CommandScanner();
152
+
153
+ // Initialize interaction handler
154
+ this.interactionHandler = new InteractionHandler(this.overlay, {
155
+ bubbleDelay: this.options.bubbleDelay,
156
+ dropdownOffset: this.options.dropdownOffset,
157
+ onWordClick: this.options.onWordClick,
158
+ onWordHover: this.options.onWordHover,
159
+ styleManager: this.styleManager, // Pass style manager for bubbles/dropdowns
160
+ commandHandlers: this.commandHandlers, // Pass handlers for bubble content
161
+ textarea: this.textarea // Pass textarea for on-select display updates
162
+ });
163
+
164
+ // Initialize features
165
+ this.initializeFeatures();
166
+
167
+ // Setup textarea event listeners
168
+ this.setupTextareaListeners();
169
+
170
+ // Load command map
171
+ if (this.options.autoLoadCommandMap) {
172
+ await this.loadCommandMap();
173
+ } else if (this.options.commandMap) {
174
+ this.updateCommandMap(this.options.commandMap);
175
+ }
176
+
177
+ // Initial render
178
+ this.render();
179
+
180
+ this.isReady = true;
181
+
182
+ // Auto-focus textarea to show cursor
183
+ setTimeout(() => {
184
+ this.textarea.focus();
185
+ }, 100);
186
+
187
+ console.log('[TrustQuery] Initialization complete');
188
+ }
189
+
190
+ /**
191
+ * Initialize optional features based on configuration
192
+ */
193
+ initializeFeatures() {
194
+ // Auto-grow textarea feature
195
+ if (this.options.autoGrow) {
196
+ this.features.autoGrow = new AutoGrow(this.textarea, {
197
+ maxHeight: this.options.autoGrowMaxHeight,
198
+ minHeight: 44
199
+ });
200
+ console.log('[TrustQuery] AutoGrow feature enabled');
201
+ }
202
+
203
+ // Debug logging feature
204
+ if (this.options.debug) {
205
+ this.enableDebugLogging();
206
+ console.log('[TrustQuery] Debug logging enabled');
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Enable debug logging for events
212
+ */
213
+ enableDebugLogging() {
214
+ const originalOnWordHover = this.options.onWordHover;
215
+ const originalOnWordClick = this.options.onWordClick;
216
+
217
+ this.options.onWordHover = (matchData) => {
218
+ console.log('[TrustQuery Debug] Word Hover:', matchData);
219
+ if (originalOnWordHover) originalOnWordHover(matchData);
220
+ };
221
+
222
+ this.options.onWordClick = (matchData) => {
223
+ console.log('[TrustQuery Debug] Word Click:', matchData);
224
+ if (originalOnWordClick) originalOnWordClick(matchData);
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Create the overlay structure
230
+ */
231
+ createOverlayStructure() {
232
+ // Create wrapper to contain both textarea and overlay
233
+ const wrapper = document.createElement('div');
234
+ wrapper.className = 'tq-wrapper';
235
+
236
+ // Wrap textarea
237
+ this.textarea.parentNode.insertBefore(wrapper, this.textarea);
238
+ wrapper.appendChild(this.textarea);
239
+
240
+ // Add TrustQuery class to textarea
241
+ this.textarea.classList.add('tq-textarea');
242
+
243
+ // Create overlay
244
+ this.overlay = document.createElement('div');
245
+ this.overlay.className = 'tq-overlay';
246
+ wrapper.appendChild(this.overlay);
247
+
248
+ // Store wrapper reference
249
+ this.wrapper = wrapper;
250
+
251
+ // Apply all inline styles via StyleManager
252
+ this.styleManager.applyAllStyles(wrapper, this.textarea, this.overlay);
253
+
254
+ // Show textarea now that structure is ready (prevents FOUC)
255
+ this.textarea.style.opacity = '1';
256
+
257
+ console.log('[TrustQuery] Overlay structure created with inline styles');
258
+ }
259
+
260
+ /**
261
+ * Setup textarea event listeners
262
+ */
263
+ setupTextareaListeners() {
264
+ // Input event - re-render on content change
265
+ this.textarea.addEventListener('input', () => {
266
+ this.render();
267
+ });
268
+
269
+ // Scroll event - sync overlay scroll with textarea
270
+ this.textarea.addEventListener('scroll', () => {
271
+ this.overlay.scrollTop = this.textarea.scrollTop;
272
+ this.overlay.scrollLeft = this.textarea.scrollLeft;
273
+ });
274
+
275
+ // Focus/blur events - add/remove focus class
276
+ this.textarea.addEventListener('focus', () => {
277
+ this.wrapper.classList.add('tq-focused');
278
+ });
279
+
280
+ this.textarea.addEventListener('blur', (e) => {
281
+ // Close dropdown when textarea loses focus (unless interacting with dropdown)
282
+ if (this.interactionHandler) {
283
+ // Use setTimeout to let the new focus target be set and check if clicking on dropdown
284
+ setTimeout(() => {
285
+ const activeElement = document.activeElement;
286
+ const isDropdownRelated = activeElement && (
287
+ activeElement.classList.contains('tq-dropdown-filter') ||
288
+ activeElement.closest('.tq-dropdown') // Check if clicking anywhere in dropdown
289
+ );
290
+
291
+ // Only close if not interacting with dropdown
292
+ if (!isDropdownRelated) {
293
+ this.interactionHandler.hideDropdown();
294
+ }
295
+
296
+ // Remove focus class only if we're truly leaving the component
297
+ if (!isDropdownRelated) {
298
+ this.wrapper.classList.remove('tq-focused');
299
+ }
300
+ }, 0);
301
+ }
302
+ });
303
+
304
+ console.log('[TrustQuery] Textarea listeners attached');
305
+ }
306
+
307
+ /**
308
+ * Load command map (static tql-triggers.json or from URL)
309
+ */
310
+ async loadCommandMap() {
311
+ try {
312
+ // Default to static file if no URL provided
313
+ const url = this.options.commandMapUrl || '/trustquery/tql-triggers.json';
314
+
315
+ console.log('[TrustQuery] Loading trigger map from:', url);
316
+ const response = await fetch(url);
317
+
318
+ if (!response.ok) {
319
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
320
+ }
321
+
322
+ const data = await response.json();
323
+ this.updateCommandMap(data);
324
+
325
+ console.log('[TrustQuery] Trigger map loaded successfully');
326
+ } catch (error) {
327
+ console.error('[TrustQuery] Failed to load trigger map:', error);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Update command map
333
+ * @param {Object} commandMap - New command map
334
+ */
335
+ updateCommandMap(commandMap) {
336
+ this.commandMap = commandMap;
337
+ this.scanner.setCommandMap(commandMap);
338
+ console.log('[TrustQuery] Command map updated');
339
+
340
+ // Re-render with new command map
341
+ if (this.isReady) {
342
+ this.render();
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Render the overlay with styled text
348
+ */
349
+ render() {
350
+ const text = this.textarea.value;
351
+
352
+ // Scan text for matches
353
+ const matches = this.scanner.scan(text);
354
+
355
+ // Render overlay with matches
356
+ this.renderer.render(text, matches);
357
+
358
+ // Update interaction handler with new elements
359
+ this.interactionHandler.update();
360
+ }
361
+
362
+ /**
363
+ * Destroy instance and cleanup
364
+ */
365
+ destroy() {
366
+ console.log('[TrustQuery] Destroying instance');
367
+
368
+ // Remove event listeners
369
+ this.textarea.removeEventListener('input', this.render);
370
+ this.textarea.removeEventListener('scroll', this.syncScroll);
371
+
372
+ // Cleanup interaction handler
373
+ if (this.interactionHandler) {
374
+ this.interactionHandler.destroy();
375
+ }
376
+
377
+ // Unwrap textarea
378
+ const parent = this.wrapper.parentNode;
379
+ parent.insertBefore(this.textarea, this.wrapper);
380
+ parent.removeChild(this.wrapper);
381
+
382
+ // Remove from instances
383
+ TrustQuery.instances.delete(this.textarea);
384
+
385
+ console.log('[TrustQuery] Destroyed');
386
+ }
387
+
388
+ /**
389
+ * Get current value
390
+ */
391
+ getValue() {
392
+ return this.textarea.value;
393
+ }
394
+
395
+ /**
396
+ * Set value
397
+ */
398
+ setValue(value) {
399
+ this.textarea.value = value;
400
+ this.render();
401
+ }
402
+ }