UNPKG

@trustquery/browser

Version:

Turn any textarea into an interactive trigger-based editor with inline styling

567 lines (476 loc) 19 kB
// TrustQuery - Lightweight library to make textareas interactive // Turns matching words into interactive elements with hover bubbles and click actions import OverlayRenderer from './OverlayRenderer.js'; import CommandScanner from './CommandScanner.js'; import InteractionHandler from './InteractionHandler.js'; import StyleManager from './StyleManager.js'; import CommandHandlerRegistry from './CommandHandlers.js'; import AutoGrow from './AutoGrow.js'; import ValidationStateManager from './ValidationStateManager.js'; import MobileKeyboardHandler from './MobileKeyboardHandler.js'; // Import attachment managers for re-export import AttachmentManager from './AttachmentManager.js'; import AttachmentStyleManager from './AttachmentStyleManager.js'; import CSVModalManager from './CSVModalManager.js'; import CSVModalStyleManager from './CSVModalStyleManager.js'; export default class TrustQuery { // Store all instances static instances = new Map(); /** * Initialize TrustQuery on a textarea * @param {string|HTMLElement} textareaId - ID or element of textarea * @param {Object} options - Configuration options * @returns {TrustQuery} Instance */ static init(textareaId, options = {}) { const textarea = typeof textareaId === 'string' ? document.getElementById(textareaId) : textareaId; if (!textarea) { console.error('[TrustQuery] Textarea not found:', textareaId); return null; } // Check if already initialized const existingInstance = TrustQuery.instances.get(textarea); if (existingInstance) { console.warn('[TrustQuery] Already initialized on this textarea, returning existing instance'); return existingInstance; } // Create new instance const instance = new TrustQuery(textarea, options); TrustQuery.instances.set(textarea, instance); console.log('[TrustQuery] Initialized successfully on:', textarea.id || textarea); return instance; } /** * Get existing instance * @param {string|HTMLElement} textareaId - ID or element * @returns {TrustQuery|null} Instance or null */ static getInstance(textareaId) { const textarea = typeof textareaId === 'string' ? document.getElementById(textareaId) : textareaId; return TrustQuery.instances.get(textarea) || null; } /** * Create a TrustQuery instance * @param {HTMLElement} textarea - Textarea element * @param {Object} options - Configuration */ constructor(textarea, options = {}) { this.textarea = textarea; // Normalize options (support both old and new API) this.options = this.normalizeOptions(options); this.commandMap = null; this.isReady = false; this.features = {}; // Initialize components this.init(); } /** * Normalize options - support both old and new API * @param {Object} options - Raw options * @returns {Object} Normalized options */ normalizeOptions(options) { // New API structure const triggerMap = options.triggerMap || {}; const features = options.features || {}; const ui = options.ui || {}; const events = options.events || {}; // Build normalized config (backward compatible) return { // Trigger map configuration commandMapUrl: triggerMap.url || options.commandMapUrl || null, commandMap: triggerMap.data || options.commandMap || null, autoLoadCommandMap: options.autoLoadCommandMap !== false, triggerMapSource: triggerMap.source || (triggerMap.url ? 'url' : triggerMap.data ? 'inline' : null), triggerMapApi: triggerMap.api || null, // Features autoGrow: features.autoGrow || false, autoGrowMaxHeight: features.maxHeight || 300, debug: features.debug || false, // UI settings theme: ui.theme || options.theme || 'light', bubbleDelay: ui.bubbleDelay !== undefined ? ui.bubbleDelay : (options.bubbleDelay || 200), dropdownOffset: ui.dropdownOffset !== undefined ? ui.dropdownOffset : (options.dropdownOffset || 28), // Events (callbacks) onWordClick: events.onWordClick || options.onWordClick || null, onWordHover: events.onWordHover || options.onWordHover || null, onValidationChange: events.onValidationChange || options.onValidationChange || null, // Theme/style options (passed to StyleManager) backgroundColor: ui.backgroundColor || options.backgroundColor, textColor: ui.textColor || options.textColor, caretColor: ui.caretColor || options.caretColor, borderColor: ui.borderColor || options.borderColor, borderColorFocus: ui.borderColorFocus || options.borderColorFocus, matchBackgroundColor: ui.matchBackgroundColor || options.matchBackgroundColor, matchTextColor: ui.matchTextColor || options.matchTextColor, matchHoverBackgroundColor: ui.matchHoverBackgroundColor || options.matchHoverBackgroundColor, fontFamily: ui.fontFamily || options.fontFamily, fontSize: ui.fontSize || options.fontSize, lineHeight: ui.lineHeight || options.lineHeight, ...options }; } /** * Initialize all components */ async init() { console.log('[TrustQuery] Starting initialization...'); // Initialize command handler registry this.commandHandlers = new CommandHandlerRegistry(); // Initialize style manager (handles all inline styling) this.styleManager = new StyleManager(this.options); // Create wrapper and overlay structure this.createOverlayStructure(); // Initialize renderer this.renderer = new OverlayRenderer(this.overlay, { theme: this.options.theme, commandHandlers: this.commandHandlers, // Pass handlers for styling debug: this.options.debug // Pass debug flag }); // Initialize scanner (will be configured when command map loads) this.scanner = new CommandScanner({ debug: this.options.debug }); // Initialize interaction handler this.interactionHandler = new InteractionHandler(this.overlay, { bubbleDelay: this.options.bubbleDelay, dropdownOffset: this.options.dropdownOffset, onWordClick: this.options.onWordClick, onWordHover: this.options.onWordHover, styleManager: this.styleManager, // Pass style manager for bubbles/dropdowns commandHandlers: this.commandHandlers, // Pass handlers for bubble content textarea: this.textarea, // Pass textarea for on-select display updates debug: this.options.debug // Pass debug flag }); // Initialize validation state manager this.validationStateManager = new ValidationStateManager({ onValidationChange: this.options.onValidationChange, debug: this.options.debug }); // Initialize features this.initializeFeatures(); // Setup textarea event listeners this.setupTextareaListeners(); // Load command map if (this.options.autoLoadCommandMap) { await this.loadCommandMap(); } else if (this.options.commandMap) { this.updateCommandMap(this.options.commandMap); } // Initial render this.render(); this.isReady = true; // Auto-focus textarea to show cursor setTimeout(() => { this.textarea.focus(); }, 100); console.log('[TrustQuery] Initialization complete'); } /** * Initialize optional features based on configuration */ initializeFeatures() { // Auto-grow textarea feature if (this.options.autoGrow) { this.features.autoGrow = new AutoGrow(this.textarea, { maxHeight: this.options.autoGrowMaxHeight, minHeight: 44 }); console.log('[TrustQuery] AutoGrow feature enabled'); } // Mobile keyboard handler (enabled by default, can be disabled via options) if (this.options.mobileKeyboard !== false) { this.features.mobileKeyboard = new MobileKeyboardHandler({ textarea: this.textarea, wrapper: this.wrapper, debug: this.options.debug }); this.features.mobileKeyboard.init(); console.log('[TrustQuery] Mobile keyboard handler enabled'); } // Debug logging feature if (this.options.debug) { this.enableDebugLogging(); console.log('[TrustQuery] Debug logging enabled'); } } /** * Enable debug logging for events */ enableDebugLogging() { const originalOnWordHover = this.options.onWordHover; const originalOnWordClick = this.options.onWordClick; this.options.onWordHover = (matchData) => { console.log('[TrustQuery Debug] Word Hover:', matchData); if (originalOnWordHover) originalOnWordHover(matchData); }; this.options.onWordClick = (matchData) => { console.log('[TrustQuery Debug] Word Click:', matchData); if (originalOnWordClick) originalOnWordClick(matchData); }; } /** * Create the overlay structure */ createOverlayStructure() { // Create wrapper to contain both textarea and overlay const wrapper = document.createElement('div'); wrapper.className = 'tq-wrapper'; // Wrap textarea this.textarea.parentNode.insertBefore(wrapper, this.textarea); wrapper.appendChild(this.textarea); // Add TrustQuery class to textarea this.textarea.classList.add('tq-textarea'); // Create overlay this.overlay = document.createElement('div'); this.overlay.className = 'tq-overlay'; wrapper.appendChild(this.overlay); // Store wrapper reference this.wrapper = wrapper; // Apply all inline styles via StyleManager this.styleManager.applyAllStyles(wrapper, this.textarea, this.overlay); // Show textarea now that structure is ready (prevents FOUC) this.textarea.style.opacity = '1'; console.log('[TrustQuery] Overlay structure created with inline styles'); } /** * Setup textarea event listeners */ setupTextareaListeners() { // Input event - re-render on content change this.textarea.addEventListener('input', () => { this.render(); }); // Scroll event - sync overlay scroll with textarea this.textarea.addEventListener('scroll', () => { this.overlay.scrollTop = this.textarea.scrollTop; this.overlay.scrollLeft = this.textarea.scrollLeft; }); // Keyboard event logging for debugging selection issues this.textarea.addEventListener('keydown', (e) => { const isCmdOrCtrl = e.metaKey || e.ctrlKey; const isSelectAll = (e.metaKey || e.ctrlKey) && e.key === 'a'; if (isCmdOrCtrl || isSelectAll) { console.log('[TrustQuery] ===== KEYBOARD EVENT ====='); console.log('[TrustQuery] Key pressed:', { key: e.key, code: e.code, metaKey: e.metaKey, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, isSelectAll: isSelectAll }); console.log('[TrustQuery] Active element:', document.activeElement.tagName, document.activeElement.id || '(no id)'); console.log('[TrustQuery] Textarea state BEFORE:', { value: this.textarea.value, valueLength: this.textarea.value.length, selectionStart: this.textarea.selectionStart, selectionEnd: this.textarea.selectionEnd, selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd) }); if (isSelectAll) { // Log state after select all (use setTimeout to let browser process the event) setTimeout(() => { console.log('[TrustQuery] Textarea state AFTER CMD+A:', { selectionStart: this.textarea.selectionStart, selectionEnd: this.textarea.selectionEnd, selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd), selectedLength: this.textarea.selectionEnd - this.textarea.selectionStart }); console.log('[TrustQuery] ===== KEYBOARD EVENT END ====='); }, 0); } else { console.log('[TrustQuery] ===== KEYBOARD EVENT END ====='); } } }); // Selection change event this.textarea.addEventListener('select', (e) => { console.log('[TrustQuery] ===== SELECTION CHANGE EVENT ====='); console.log('[TrustQuery] Selection:', { selectionStart: this.textarea.selectionStart, selectionEnd: this.textarea.selectionEnd, selectedText: this.textarea.value.substring(this.textarea.selectionStart, this.textarea.selectionEnd), selectedLength: this.textarea.selectionEnd - this.textarea.selectionStart }); }); // Context menu event - prevent keyboard-triggered context menu this.textarea.addEventListener('contextmenu', (e) => { console.log('[TrustQuery] ===== CONTEXTMENU EVENT ====='); console.log('[TrustQuery] Context menu triggered:', { type: e.type, isTrusted: e.isTrusted, button: e.button, buttons: e.buttons, clientX: e.clientX, clientY: e.clientY, ctrlKey: e.ctrlKey, metaKey: e.metaKey, target: e.target.tagName }); // Prevent context menu if triggered by keyboard (button === -1) // This prevents the macOS context menu from opening after CMD+A if (e.button === -1 && e.buttons === 0) { console.log('[TrustQuery] Preventing keyboard-triggered context menu'); e.preventDefault(); e.stopPropagation(); return; } console.log('[TrustQuery] Allowing mouse-triggered context menu'); }); // Also prevent context menu on overlay (it interferes with text selection) this.overlay.addEventListener('contextmenu', (e) => { console.log('[TrustQuery] ===== CONTEXTMENU EVENT ON OVERLAY ====='); console.log('[TrustQuery] Context menu on overlay - preventing'); // Always prevent context menu on overlay // The overlay should be transparent to user interactions e.preventDefault(); e.stopPropagation(); }); // Focus/blur events - add/remove focus class this.textarea.addEventListener('focus', (e) => { console.log('[TrustQuery] ===== FOCUS EVENT ====='); console.log('[TrustQuery] Textarea focused. Active element:', document.activeElement.tagName, document.activeElement.id || '(no id)'); console.log('[TrustQuery] Current selection:', { selectionStart: this.textarea.selectionStart, selectionEnd: this.textarea.selectionEnd }); this.wrapper.classList.add('tq-focused'); }); this.textarea.addEventListener('blur', (e) => { console.log('[TrustQuery] ===== BLUR EVENT ====='); console.log('[TrustQuery] Textarea blurred. Related target:', e.relatedTarget?.tagName || '(none)'); console.log('[TrustQuery] Blur event details:', { type: e.type, isTrusted: e.isTrusted, eventPhase: e.eventPhase, target: e.target.tagName, currentTarget: e.currentTarget.tagName }); console.log('[TrustQuery] Stack trace at blur:'); console.trace(); // Close dropdown when textarea loses focus (unless interacting with dropdown) if (this.interactionHandler) { // Use setTimeout to let the new focus target be set and check if clicking on dropdown setTimeout(() => { const activeElement = document.activeElement; const isDropdownRelated = activeElement && ( activeElement.classList.contains('tq-dropdown-filter') || activeElement.closest('.tq-dropdown') // Check if clicking anywhere in dropdown ); console.log('[TrustQuery] After blur - active element:', activeElement?.tagName || '(none)', 'isDropdownRelated:', isDropdownRelated); // Only close if not interacting with dropdown if (!isDropdownRelated) { this.interactionHandler.hideDropdown(); } // Remove focus class only if we're truly leaving the component if (!isDropdownRelated) { this.wrapper.classList.remove('tq-focused'); } }, 0); } }); console.log('[TrustQuery] Textarea listeners attached'); } /** * Load command map (static tql-triggers.json or from URL) */ async loadCommandMap() { try { // Default to static file if no URL provided const url = this.options.commandMapUrl || '/trustquery/tql-triggers.json'; console.log('[TrustQuery] Loading trigger map from:', url); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); this.updateCommandMap(data); console.log('[TrustQuery] Trigger map loaded successfully'); } catch (error) { console.error('[TrustQuery] Failed to load trigger map:', error); } } /** * Update command map * @param {Object} commandMap - New command map */ updateCommandMap(commandMap) { this.commandMap = commandMap; this.scanner.setCommandMap(commandMap); console.log('[TrustQuery] Command map updated'); // Re-render with new command map if (this.isReady) { this.render(); } } /** * Render the overlay with styled text */ render() { const text = this.textarea.value; // Scan text for matches const matches = this.scanner.scan(text); // Render overlay with matches this.renderer.render(text, matches); // Update interaction handler with new elements this.interactionHandler.update(); // Update validation state if (this.validationStateManager) { this.validationStateManager.update(matches); } } /** * Destroy instance and cleanup */ destroy() { console.log('[TrustQuery] Destroying instance'); // Remove event listeners this.textarea.removeEventListener('input', this.render); this.textarea.removeEventListener('scroll', this.syncScroll); // Cleanup interaction handler if (this.interactionHandler) { this.interactionHandler.destroy(); } // Cleanup mobile keyboard handler if (this.features.mobileKeyboard) { this.features.mobileKeyboard.destroy(); } // Cleanup auto-grow if (this.features.autoGrow) { this.features.autoGrow.destroy(); } // Unwrap textarea const parent = this.wrapper.parentNode; parent.insertBefore(this.textarea, this.wrapper); parent.removeChild(this.wrapper); // Remove from instances TrustQuery.instances.delete(this.textarea); console.log('[TrustQuery] Destroyed'); } /** * Get current value */ getValue() { return this.textarea.value; } /** * Set value */ setValue(value) { this.textarea.value = value; this.render(); } } // Export attachment managers as named exports export { AttachmentManager, AttachmentStyleManager, CSVModalManager, CSVModalStyleManager };