UNPKG

@semantest/chrome-extension

Version:

Browser extension for ChatGPT-buddy - AI automation extension built on Web-Buddy framework

514 lines (513 loc) 19.5 kB
/** * @fileoverview Plugin UI Component System for Web-Buddy plugin architecture * @description Manages plugin UI components, menus, and state with lifecycle support */ import { PluginError } from './plugin-interface'; /** * UI event types for plugin system */ export var UIEventType; (function (UIEventType) { UIEventType["COMPONENT_REGISTERED"] = "ui:component:registered"; UIEventType["COMPONENT_MOUNTED"] = "ui:component:mounted"; UIEventType["COMPONENT_UNMOUNTED"] = "ui:component:unmounted"; UIEventType["COMPONENT_ERROR"] = "ui:component:error"; UIEventType["MENU_ITEM_REGISTERED"] = "ui:menu:registered"; UIEventType["MENU_ITEM_CLICKED"] = "ui:menu:clicked"; UIEventType["UI_STATE_CHANGED"] = "ui:state:changed"; })(UIEventType || (UIEventType = {})); /** * Plugin UI Manager - manages all plugin UI components and menus */ export class PluginUIManager { constructor(eventBus, logger) { this.components = new Map(); this.menuItems = new Map(); this.mountPoints = new Map(); this.eventBus = eventBus; this.logger = logger; this.uiState = { activeComponents: new Set(), visibleMenus: new Set(), theme: 'auto', language: 'en', uiScale: 1.0 }; this.initializeMountPoints(); this.setupEventListeners(); } /** * Register a UI component for a plugin */ registerComponent(pluginId, component) { try { if (this.components.has(component.id)) { throw new PluginError(`UI component with id ${component.id} already registered`, pluginId, 'UI_COMPONENT_DUPLICATE'); } const entry = { component, state: 'unmounted', pluginId, metadata: { createdAt: new Date(), errorCount: 0, renderCount: 0 } }; this.components.set(component.id, entry); this.eventBus.emit({ type: UIEventType.COMPONENT_REGISTERED, source: 'plugin-ui-manager', target: pluginId, data: { componentId: component.id, type: component.type }, timestamp: new Date().toISOString() }); this.logger.info(`UI component registered: ${component.id}`, { pluginId, componentType: component.type, componentName: component.name }); } catch (error) { this.logger.error(`Failed to register UI component: ${component.id}`, error, { pluginId }); throw error; } } /** * Unregister a UI component */ async unregisterComponent(componentId) { const entry = this.components.get(componentId); if (!entry) { this.logger.warn(`Attempted to unregister non-existent component: ${componentId}`); return; } try { // Unmount component if mounted if (entry.state === 'mounted') { await this.unmountComponent(componentId); } this.components.delete(componentId); this.uiState.activeComponents.delete(componentId); this.logger.info(`UI component unregistered: ${componentId}`, { pluginId: entry.pluginId }); } catch (error) { this.logger.error(`Failed to unregister UI component: ${componentId}`, error); throw error; } } /** * Mount a UI component to its designated mount point */ async mountComponent(componentId, mountPoint) { const entry = this.components.get(componentId); if (!entry) { throw new PluginError(`Cannot mount non-existent component: ${componentId}`, 'unknown', 'UI_COMPONENT_NOT_FOUND'); } if (entry.state === 'mounted') { this.logger.warn(`Component already mounted: ${componentId}`); return; } try { entry.state = 'mounting'; entry.metadata.renderCount++; // Determine mount point const targetMountPoint = mountPoint || this.getMountPointForComponent(entry.component); if (!targetMountPoint) { throw new PluginError(`No mount point available for component type: ${entry.component.type}`, entry.pluginId, 'UI_MOUNT_POINT_UNAVAILABLE'); } // Render component const element = await entry.component.render(); if (!element) { throw new PluginError(`Component render() returned null/undefined: ${componentId}`, entry.pluginId, 'UI_RENDER_FAILED'); } // Apply styling and accessibility this.applyComponentStyling(element, entry.component); this.setupComponentAccessibility(element, entry.component); // Mount to DOM targetMountPoint.appendChild(element); entry.element = element; entry.mountPoint = targetMountPoint; entry.state = 'mounted'; // Call lifecycle hook if (entry.component.onMount) { await entry.component.onMount(); } this.uiState.activeComponents.add(componentId); this.eventBus.emit({ type: UIEventType.COMPONENT_MOUNTED, source: 'plugin-ui-manager', target: entry.pluginId, data: { componentId, mountPoint: targetMountPoint.id || 'unnamed' }, timestamp: new Date().toISOString() }); this.logger.info(`UI component mounted: ${componentId}`, { pluginId: entry.pluginId, mountPoint: targetMountPoint.id || 'unnamed' }); } catch (error) { entry.state = 'error'; entry.metadata.errorCount++; this.eventBus.emit({ type: UIEventType.COMPONENT_ERROR, source: 'plugin-ui-manager', target: entry.pluginId, data: { componentId, error: error.message }, timestamp: new Date().toISOString() }); this.logger.error(`Failed to mount UI component: ${componentId}`, error, { pluginId: entry.pluginId }); throw error; } } /** * Unmount a UI component from the DOM */ async unmountComponent(componentId) { const entry = this.components.get(componentId); if (!entry || entry.state !== 'mounted') { this.logger.warn(`Attempted to unmount component in invalid state: ${componentId}`); return; } try { entry.state = 'unmounting'; // Call lifecycle hook if (entry.component.onUnmount) { await entry.component.onUnmount(); } // Remove from DOM if (entry.element && entry.element.parentNode) { entry.element.parentNode.removeChild(entry.element); } entry.element = undefined; entry.mountPoint = undefined; entry.state = 'unmounted'; this.uiState.activeComponents.delete(componentId); this.eventBus.emit({ type: UIEventType.COMPONENT_UNMOUNTED, source: 'plugin-ui-manager', target: entry.pluginId, data: { componentId }, timestamp: new Date().toISOString() }); this.logger.info(`UI component unmounted: ${componentId}`, { pluginId: entry.pluginId }); } catch (error) { entry.state = 'error'; entry.metadata.errorCount++; this.logger.error(`Failed to unmount UI component: ${componentId}`, error, { pluginId: entry.pluginId }); throw error; } } /** * Register a menu item for a plugin */ registerMenuItem(pluginId, menuItem) { try { if (this.menuItems.has(menuItem.id)) { throw new PluginError(`Menu item with id ${menuItem.id} already registered`, pluginId, 'UI_MENU_ITEM_DUPLICATE'); } const entry = { item: menuItem, pluginId, metadata: { createdAt: new Date(), clickCount: 0 } }; this.menuItems.set(menuItem.id, entry); // Create menu item element const element = this.createMenuItemElement(entry); entry.element = element; // Add to appropriate menu this.addMenuItemToMenu(entry); this.eventBus.emit({ type: UIEventType.MENU_ITEM_REGISTERED, source: 'plugin-ui-manager', target: pluginId, data: { menuItemId: menuItem.id, label: menuItem.label }, timestamp: new Date().toISOString() }); this.logger.info(`Menu item registered: ${menuItem.id}`, { pluginId, label: menuItem.label }); } catch (error) { this.logger.error(`Failed to register menu item: ${menuItem.id}`, error, { pluginId }); throw error; } } /** * Unregister a menu item */ unregisterMenuItem(menuItemId) { const entry = this.menuItems.get(menuItemId); if (!entry) { this.logger.warn(`Attempted to unregister non-existent menu item: ${menuItemId}`); return; } try { // Remove from DOM if (entry.element && entry.element.parentNode) { entry.element.parentNode.removeChild(entry.element); } this.menuItems.delete(menuItemId); this.uiState.visibleMenus.delete(menuItemId); this.logger.info(`Menu item unregistered: ${menuItemId}`, { pluginId: entry.pluginId }); } catch (error) { this.logger.error(`Failed to unregister menu item: ${menuItemId}`, error); throw error; } } /** * Update UI state */ updateUIState(updates) { const oldState = { ...this.uiState }; this.uiState = { ...this.uiState, ...updates }; this.eventBus.emit({ type: UIEventType.UI_STATE_CHANGED, source: 'plugin-ui-manager', data: { oldState, newState: this.uiState }, timestamp: new Date().toISOString() }); this.logger.debug('UI state updated', { updates }); } /** * Get components by plugin */ getComponentsByPlugin(pluginId) { return Array.from(this.components.values()) .filter(entry => entry.pluginId === pluginId) .map(entry => entry.component); } /** * Get menu items by plugin */ getMenuItemsByPlugin(pluginId) { return Array.from(this.menuItems.values()) .filter(entry => entry.pluginId === pluginId) .map(entry => entry.item); } /** * Get UI statistics */ getStatistics() { const componentStats = Array.from(this.components.values()).reduce((stats, entry) => { stats.byState[entry.state] = (stats.byState[entry.state] || 0) + 1; stats.byType[entry.component.type] = (stats.byType[entry.component.type] || 0) + 1; stats.totalRenders += entry.metadata.renderCount; stats.totalErrors += entry.metadata.errorCount; return stats; }, { byState: {}, byType: {}, totalRenders: 0, totalErrors: 0 }); const menuStats = Array.from(this.menuItems.values()).reduce((stats, entry) => { stats.totalClicks += entry.metadata.clickCount; return stats; }, { totalClicks: 0 }); return { components: { total: this.components.size, active: this.uiState.activeComponents.size, ...componentStats }, menuItems: { total: this.menuItems.size, visible: this.uiState.visibleMenus.size, ...menuStats }, uiState: this.uiState }; } // Private helper methods initializeMountPoints() { // Create default mount points for different component types const mountPoints = [ { id: 'web-buddy-panel-mount', type: 'panel' }, { id: 'web-buddy-toolbar-mount', type: 'toolbar' }, { id: 'web-buddy-sidebar-mount', type: 'sidebar' }, { id: 'web-buddy-overlay-mount', type: 'overlay' } ]; mountPoints.forEach(({ id, type }) => { let element = document.getElementById(id); if (!element) { element = document.createElement('div'); element.id = id; element.className = `web-buddy-mount web-buddy-mount-${type}`; document.body.appendChild(element); } this.mountPoints.set(type, element); }); } setupEventListeners() { // Listen for tab changes to update UI state this.eventBus.on('tab:activated', (event) => { this.updateUIState({ currentTab: event.data.tab }); }); // Listen for theme changes this.eventBus.on('ui:theme:changed', (event) => { this.updateUIState({ theme: event.data.theme }); this.applyThemeToComponents(); }); } getMountPointForComponent(component) { return this.mountPoints.get(component.type) || null; } applyComponentStyling(element, component) { element.className = `web-buddy-component web-buddy-component-${component.type}`; element.setAttribute('data-component-id', component.id); element.setAttribute('data-component-name', component.name); // Apply theme-specific styling element.setAttribute('data-theme', this.uiState.theme); } setupComponentAccessibility(element, component) { if (!element.getAttribute('role')) { element.setAttribute('role', this.getAriaRoleForComponentType(component.type)); } if (!element.getAttribute('aria-label') && component.description) { element.setAttribute('aria-label', component.description); } if (!element.getAttribute('tabindex')) { element.setAttribute('tabindex', '0'); } } getAriaRoleForComponentType(type) { const roleMap = { panel: 'region', toolbar: 'toolbar', modal: 'dialog', sidebar: 'complementary', popup: 'tooltip', overlay: 'region' }; return roleMap[type] || 'region'; } createMenuItemElement(entry) { const element = document.createElement('div'); element.className = 'web-buddy-menu-item'; element.setAttribute('data-menu-item-id', entry.item.id); element.setAttribute('role', 'menuitem'); element.setAttribute('tabindex', '0'); // Create icon if provided if (entry.item.icon) { const icon = document.createElement('span'); icon.className = 'web-buddy-menu-icon'; icon.textContent = entry.item.icon; element.appendChild(icon); } // Create label const label = document.createElement('span'); label.className = 'web-buddy-menu-label'; label.textContent = entry.item.label; element.appendChild(label); // Create shortcut if provided if (entry.item.shortcut) { const shortcut = document.createElement('span'); shortcut.className = 'web-buddy-menu-shortcut'; shortcut.textContent = entry.item.shortcut; element.appendChild(shortcut); } // Add click handler element.addEventListener('click', async () => { await this.handleMenuItemClick(entry); }); return element; } async handleMenuItemClick(entry) { try { // Check if menu item is enabled if (entry.item.enabled && !entry.item.enabled()) { this.logger.debug(`Menu item disabled: ${entry.item.id}`); return; } // Check if menu item is visible if (entry.item.visible && !entry.item.visible()) { this.logger.debug(`Menu item not visible: ${entry.item.id}`); return; } entry.metadata.clickCount++; entry.metadata.lastClicked = new Date(); await entry.item.action(); this.eventBus.emit({ type: UIEventType.MENU_ITEM_CLICKED, source: 'plugin-ui-manager', target: entry.pluginId, data: { menuItemId: entry.item.id, label: entry.item.label }, timestamp: new Date().toISOString() }); this.logger.debug(`Menu item clicked: ${entry.item.id}`, { pluginId: entry.pluginId, clickCount: entry.metadata.clickCount }); } catch (error) { this.logger.error(`Menu item action failed: ${entry.item.id}`, error, { pluginId: entry.pluginId }); } } addMenuItemToMenu(entry) { // For now, add to a default plugin menu // In a real implementation, this would add to appropriate context menus const pluginMenu = this.getOrCreatePluginMenu(entry.pluginId); if (pluginMenu && entry.element) { pluginMenu.appendChild(entry.element); this.uiState.visibleMenus.add(entry.item.id); } } getOrCreatePluginMenu(pluginId) { let menu = document.getElementById(`web-buddy-plugin-menu-${pluginId}`); if (!menu) { menu = document.createElement('div'); menu.id = `web-buddy-plugin-menu-${pluginId}`; menu.className = 'web-buddy-plugin-menu'; // Add to main menu container const menuContainer = this.getOrCreateMenuContainer(); menuContainer.appendChild(menu); } return menu; } getOrCreateMenuContainer() { let container = document.getElementById('web-buddy-menu-container'); if (!container) { container = document.createElement('div'); container.id = 'web-buddy-menu-container'; container.className = 'web-buddy-menu-container'; document.body.appendChild(container); } return container; } applyThemeToComponents() { this.components.forEach((entry) => { if (entry.element) { entry.element.setAttribute('data-theme', this.uiState.theme); } }); } } /** * Plugin UI Factory for creating UI manager instances */ export class PluginUIFactory { createUIManager(eventBus, logger) { return new PluginUIManager(eventBus, logger); } }