import React, { useEffect, useCallback, useRef } from 'react'; import { useDesignMode } from './DesignModeContext'; import { ElementInfo, SourceInfo } from '@xagi/design-mode-shared/messages'; import { AttributeNames, isSourceMappingAttribute } from '@xagi/design-mode-shared/attributeNames'; import { isPureStaticText } from '@xagi/design-mode-shared/elementUtils'; import { extractSourceInfo as extractSourceInfoFromAttributes } from '@xagi/design-mode-shared/sourceInfo'; /** * Click/hover selection for mapped elements; emits to listeners / iframe. */ export class SelectionManager { private selectedElement: HTMLElement | null = null; private selectionListeners: Set<(element: HTMLElement | null) => void> = new Set(); private hoverElement: HTMLElement | null = null; private isSelecting = false; private selectionStartTime = 0; private preventNextClick = false; constructor( private container: HTMLElement, private config: { enableSelection: boolean; enableHover: boolean; selectionDelay: number; excludeSelectors: string[]; includeOnlyElements: boolean; } = { enableSelection: true, enableHover: true, selectionDelay: 0, excludeSelectors: [ 'script', 'style', 'meta', 'link', 'head', 'title', 'html', 'body', '[data-selection-exclude="true"]', '.no-selection' ], includeOnlyElements: false } ) { this.initializeEventListeners(); } private initializeEventListeners() { if (!this.config.enableSelection) return; this.container.addEventListener('click', this.handleClick.bind(this), true); this.container.addEventListener('mousedown', this.handleMouseDown.bind(this), true); this.container.addEventListener('mouseup', this.handleMouseUp.bind(this), true); if (this.config.enableHover) { this.container.addEventListener('mouseenter', this.handleMouseEnter.bind(this), true); this.container.addEventListener('mouseleave', this.handleMouseLeave.bind(this), true); } // Keyboard shortcuts when selection enabled if (this.config.enableSelection) { document.addEventListener('keydown', this.handleKeyDown.bind(this)); document.addEventListener('keyup', this.handleKeyUp.bind(this)); } } /** * Click handler */ private handleClick(event: MouseEvent) { if (!this.config.enableSelection) return; if (this.preventNextClick) { event.preventDefault(); event.stopPropagation(); this.preventNextClick = false; return; } const target = event.target as HTMLElement; if (!this.isValidElement(target)) return; // Optional selectionDelay if (this.config.selectionDelay > 0) { setTimeout(() => { this.selectElement(target); }, this.config.selectionDelay); } else { this.selectElement(target); } } /** * mousedown */ private handleMouseDown(event: MouseEvent) { const target = event.target as HTMLElement; if (!this.isValidElement(target)) return; this.isSelecting = true; this.selectionStartTime = Date.now(); this.preventNextClick = false; } /** * mouseup */ private handleMouseUp(event: MouseEvent) { if (!this.isSelecting) return; const target = event.target as HTMLElement; const duration = Date.now() - this.selectionStartTime; // Long-press (>500ms) suppresses click selection if (duration > 500) { this.preventNextClick = true; } this.isSelecting = false; } /** * mouseenter */ private handleMouseEnter(event: MouseEvent) { if (!this.config.enableHover) return; const target = event.target as HTMLElement; if (!this.isValidElement(target)) return; this.hoverElement = target; this.onHoverElement(target); } /** * mouseleave */ private handleMouseLeave(event: MouseEvent) { if (!this.config.enableHover) return; const target = event.target as HTMLElement; if (this.hoverElement === target) { this.hoverElement = null; this.onHoverElement(null); } } /** * keydown */ private handleKeyDown(event: KeyboardEvent) { // Esc clears selection if (event.key === 'Escape') { this.clearSelection(); } // Ctrl/Cmd+A: select first match if ((event.ctrlKey || event.metaKey) && event.key === 'a') { event.preventDefault(); this.selectAll(); } // Ctrl/Cmd+D: clear if ((event.ctrlKey || event.metaKey) && event.key === 'd') { event.preventDefault(); this.clearSelection(); } } /** * keyup */ private handleKeyUp(event: KeyboardEvent) { // noop } /** * Whether element may be selected (mapped + static-content or static-class) */ private isValidElement(element: HTMLElement): boolean { if (!element || !element.tagName) return false; // Exclude context menu if (element.closest(`[${AttributeNames.contextMenu}="true"]`)) return false; // Exclusion list for (const selector of this.config.excludeSelectors) { if (element.matches(selector) || element.closest(selector)) { return false; } } // Optional tag whitelist if (this.config.includeOnlyElements) { const validElements = ['DIV', 'SPAN', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BUTTON', 'A']; if (!validElements.includes(element.tagName)) { return false; } } // Must have -info JSON if (!element.hasAttribute(AttributeNames.info)) { return false; } // Need static-content or static-class const hasStaticContent = element.hasAttribute(AttributeNames.staticContent); const hasStaticClass = element.hasAttribute(AttributeNames.staticClass); console.log('hasStaticContent', element, hasStaticContent); console.log('hasStaticClass', element, hasStaticClass); if (!hasStaticContent && !hasStaticClass) { // Dynamic content/style — skip return false; } return true; } /** * Apply selection highlight */ private selectElement(element: HTMLElement) { if (this.selectedElement === element) return; // Clear previous outline if (this.selectedElement) { this.clearElementHighlighting(this.selectedElement); } this.selectedElement = element; // Highlight new node this.highlightElement(element); // Notify subscribers this.selectionListeners.forEach(listener => listener(element)); } /** * clearSelection */ public clearSelection() { if (this.selectedElement) { this.clearElementHighlighting(this.selectedElement); this.selectedElement = null; this.selectionListeners.forEach(listener => listener(null)); } } /** * highlightElement */ private highlightElement(element: HTMLElement) { // Restore prior inline styles if any const existingHighlight = element.getAttribute('data-selection-highlight'); if (existingHighlight) { const existingStyles = JSON.parse(existingHighlight); Object.entries(existingStyles).forEach(([property, value]) => { (element.style as any)[property] = value; }); } // Snapshot pre-highlight const originalStyles = { outline: element.style.outline, boxShadow: element.style.boxShadow, backgroundColor: element.style.backgroundColor, cursor: element.style.cursor }; // Selection chrome element.style.outline = '2px solid #007acc'; element.style.boxShadow = '0 0 0 2px rgba(0, 122, 204, 0.3)'; element.style.backgroundColor = 'rgba(0, 122, 204, 0.1)'; element.style.cursor = 'pointer'; // Persist snapshot on data-selection-highlight element.setAttribute('data-selection-highlight', JSON.stringify(originalStyles)); } /** * clearElementHighlighting */ private clearElementHighlighting(element: HTMLElement) { const highlightData = element.getAttribute('data-selection-highlight'); if (highlightData) { try { const originalStyles = JSON.parse(highlightData); Object.entries(originalStyles).forEach(([property, value]) => { (element.style as any)[property] = value; }); element.removeAttribute('data-selection-highlight'); } catch (e) { console.warn('[SelectionManager] Failed to restore original styles:', e); } } } /** * onHoverElement */ private onHoverElement(element: HTMLElement | null) { // Hook for hover UX } /** * selectAll (first candidate only) */ private selectAll() { const selectableElements = Array.from( this.container.querySelectorAll('*') ).filter(el => this.isValidElement(el as HTMLElement)); // Future: multi-select if (selectableElements.length > 0) { this.selectElement(selectableElements[0] as HTMLElement); } } /** * addSelectionListener */ public addSelectionListener(listener: (element: HTMLElement | null) => void) { this.selectionListeners.add(listener); return () => this.selectionListeners.delete(listener); } /** * getSelectedElement */ public getSelectedElement(): HTMLElement | null { return this.selectedElement; } /** * getHoverElement */ public getHoverElement(): HTMLElement | null { return this.hoverElement; } /** * extractSourceInfo */ private extractSourceInfo(element: HTMLElement): SourceInfo | null { return extractSourceInfoFromAttributes(element); } /** * Walk up for library component root */ private findComponentRoot(element: HTMLElement): HTMLElement { const sourceInfo = this.extractSourceInfo(element); if (!sourceInfo || !sourceInfo.fileName) return element; // Heuristic: components/ui or common const isLibraryComponent = sourceInfo.fileName.includes('/components/ui/') || sourceInfo.fileName.includes('/components/common/'); if (!isLibraryComponent) return element; // Stop at file boundary let current = element; let componentRoot = element; while (current.parentElement) { const parent = current.parentElement; const parentSourceInfo = this.extractSourceInfo(parent); // Boundary: different file or no mapping if (!parentSourceInfo || parentSourceInfo.fileName !== sourceInfo.fileName) { componentRoot = current; break; } current = parent; } return componentRoot; } /** * extractElementInfo */ public extractElementInfo(element: HTMLElement): ElementInfo | null { if (!element) return null; // Prefer library root const targetElement = this.findComponentRoot(element); const sourceInfo = this.extractSourceInfo(targetElement); if (!sourceInfo) { console.warn('[SelectionManager] Element has no source mapping:', targetElement); return null; } // Layout (rect unused below — kept for future) const rect = targetElement.getBoundingClientRect(); const computedStyle = window.getComputedStyle(targetElement); // Static text flags const hasStaticContentAttr = targetElement.hasAttribute(AttributeNames.staticContent); const isActuallyPureText = isPureStaticText(targetElement); const isStaticText = hasStaticContentAttr && isActuallyPureText; const isStaticClass = targetElement.hasAttribute(AttributeNames.staticClass); // Text snapshot let textContent = ''; if (isStaticText) { textContent = this.getElementTextContent(targetElement); } else { textContent = targetElement.innerText || targetElement.textContent || ''; } // Ancestor chain with mappings const hierarchy: { tagName: string; componentName?: string; fileName?: string }[] = []; let current: HTMLElement | null = targetElement; while (current && current !== document.body) { const info = this.extractSourceInfo(current); if (info) { hierarchy.push({ tagName: current.tagName.toLowerCase(), componentName: info.componentName, fileName: info.fileName }); } current = current.parentElement; } // DOM attrs as pseudo-props const props = this.getElementAttributes(targetElement); return { tagName: targetElement.tagName.toLowerCase(), className: targetElement.className || '', textContent: textContent, sourceInfo, isStaticText: isStaticText || false, isStaticClass: isStaticClass, componentName: sourceInfo.componentName, componentPath: sourceInfo.fileName, props, hierarchy }; } /** * getElementTextContent */ private getElementTextContent(element: HTMLElement): string { let textContent = element.textContent || ''; // Truncate preview if (textContent.length > 100) { textContent = textContent.substring(0, 100) + '...'; } return textContent.trim(); } /** * getElementAttributes */ private getElementAttributes(element: HTMLElement): Record { const attributes: Record = {}; const elementAttributes = Array.from(element.attributes); elementAttributes.forEach(attr => { // Strip mapping + selection attrs if (!isSourceMappingAttribute(attr.name) && !attr.name.startsWith('data-selection-')) { attributes[attr.name] = attr.value; } }); return attributes; } /** * getElementDomPath */ private getElementDomPath(element: HTMLElement): string { const path: string[] = []; let current: HTMLElement | null = element; while (current && current !== this.container) { let selector = current.tagName.toLowerCase(); if (current.id) { selector += `#${current.id}`; path.unshift(selector); break; } if (current.className) { const classes = Array.from(current.classList).slice(0, 3); selector += `.${classes.join('.')}`; } // nth-of-type disambiguation const siblings = Array.from(current.parentNode?.children || []); const sameTagSiblings = siblings.filter(sibling => sibling.tagName === current!.tagName ); if (sameTagSiblings.length > 1) { const index = sameTagSiblings.indexOf(current); selector += `:nth-of-type(${index + 1})`; } path.unshift(selector); current = current.parentElement; } return path.join(' > '); } /** * destroy */ public destroy() { this.clearSelection(); this.container.removeEventListener('click', this.handleClick.bind(this), true); this.container.removeEventListener('mousedown', this.handleMouseDown.bind(this), true); this.container.removeEventListener('mouseup', this.handleMouseUp.bind(this), true); this.container.removeEventListener('mouseenter', this.handleMouseEnter.bind(this), true); this.container.removeEventListener('mouseleave', this.handleMouseLeave.bind(this), true); document.removeEventListener('keydown', this.handleKeyDown.bind(this)); document.removeEventListener('keyup', this.handleKeyUp.bind(this)); this.selectionListeners.clear(); } } /** * SelectionManager React Hook */ export const useSelectionManager = (config?: { enableSelection?: boolean; enableHover?: boolean; selectionDelay?: number; excludeSelectors?: string[]; includeOnlyElements?: boolean; }) => { const selectionManagerRef = useRef(null); const { selectElement, config: designModeConfig } = useDesignMode(); useEffect(() => { const container = document.body; selectionManagerRef.current = new SelectionManager(container, { enableSelection: designModeConfig.iframeMode?.enableSelection ?? true, enableHover: true, selectionDelay: 0, excludeSelectors: [ 'script', 'style', 'meta', 'link', 'head', 'title', 'html', 'body', '[data-selection-exclude="true"]', '.no-selection' ], includeOnlyElements: false, ...config }); // Bridge to React context const unsubscribe = selectionManagerRef.current.addSelectionListener((element) => { if (element && designModeConfig.iframeMode?.enabled) { // extractElementInfo + selectElement const elementInfo = selectionManagerRef.current?.extractElementInfo(element); if (elementInfo) { selectElement(element); } } else if (!element) { selectElement(null); } }); return () => { unsubscribe(); selectionManagerRef.current?.destroy(); selectionManagerRef.current = null; }; }, [selectElement, designModeConfig]); return { selectionManager: selectionManagerRef.current }; }; export default SelectionManager;