/** * Selector Utility * * Handles detection and categorization of clicked elements inside the iframe. * Provides utilities for: * - Determining element category (text, image, container, etc.) * - Setting up click listeners on iframe content * - Extracting element metadata for the style panel */ import { ElementCategory, SelectedElement, CanvasClickEvent } from '../types/editor'; // Tags categorized by their type for style panel rendering const TEXT_TAGS = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'LABEL', 'A', 'STRONG', 'EM', 'B', 'I', 'U', 'SMALL', 'MARK', 'DEL', 'INS', 'SUB', 'SUP', 'BLOCKQUOTE', 'PRE', 'CODE']; const IMAGE_TAGS = ['IMG', 'PICTURE', 'SVG', 'VIDEO']; const CONTAINER_TAGS = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'ASIDE', 'HEADER', 'FOOTER', 'NAV', 'FIGURE']; const BUTTON_TAGS = ['BUTTON']; const LINK_TAGS = ['A']; const LIST_TAGS = ['UL', 'OL', 'LI']; /** * Determines the category of an HTML element based on its tag name */ export function getElementCategory(element: HTMLElement): ElementCategory { const tagName = element.tagName.toUpperCase(); // Check if it's a link first (A tags can contain text) if (LINK_TAGS.includes(tagName)) { return 'link'; } if (BUTTON_TAGS.includes(tagName)) { return 'button'; } if (TEXT_TAGS.includes(tagName)) { return 'text'; } if (IMAGE_TAGS.includes(tagName)) { return 'image'; } if (CONTAINER_TAGS.includes(tagName)) { return 'container'; } if (LIST_TAGS.includes(tagName)) { return 'list'; } return 'unknown'; } /** * Creates a SelectedElement object from a raw HTMLElement */ export function createSelectedElement( element: HTMLElement, iframeWindow: Window ): SelectedElement { const rect = element.getBoundingClientRect(); const computedStyles = iframeWindow.getComputedStyle(element); return { node: element, tagName: element.tagName.toLowerCase(), category: getElementCategory(element), rect, computedStyles }; } /** * Sets up click event listener on iframe document * Returns a cleanup function to remove the listener */ export function setupIframeClickListener( iframeDocument: Document, iframeWindow: Window, onElementClick: (element: SelectedElement) => void, onElementHover?: (element: SelectedElement | null) => void ): () => void { const handleClick = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); const target = event.target as HTMLElement; // Skip if clicking on html or body directly if (target === iframeDocument.documentElement || target === iframeDocument.body) { return; } const selectedElement = createSelectedElement(target, iframeWindow); onElementClick(selectedElement); }; const handleMouseMove = (event: MouseEvent) => { if (!onElementHover) return; const target = event.target as HTMLElement; if (target === iframeDocument.documentElement || target === iframeDocument.body) { onElementHover(null); return; } const hoveredElement = createSelectedElement(target, iframeWindow); onElementHover(hoveredElement); }; const handleMouseLeave = () => { if (onElementHover) { onElementHover(null); } }; // Add listeners iframeDocument.addEventListener('click', handleClick, true); iframeDocument.addEventListener('mousemove', handleMouseMove, true); iframeDocument.addEventListener('mouseleave', handleMouseLeave, true); // Return cleanup function return () => { iframeDocument.removeEventListener('click', handleClick, true); iframeDocument.removeEventListener('mousemove', handleMouseMove, true); iframeDocument.removeEventListener('mouseleave', handleMouseLeave, true); }; } /** * Updates the rect of a selected element (useful after scroll/resize) */ export function updateElementRect( element: SelectedElement, iframeWindow: Window ): SelectedElement { return { ...element, rect: element.node.getBoundingClientRect(), computedStyles: iframeWindow.getComputedStyle(element.node) }; } /** * Finds the closest selectable parent element * Useful when clicking on text nodes or inline elements */ export function findSelectableParent(element: HTMLElement): HTMLElement | null { let current: HTMLElement | null = element; while (current) { const display = window.getComputedStyle(current).display; // Skip inline elements that are too granular if (display !== 'inline' || current.tagName === 'A' || current.tagName === 'BUTTON') { return current; } current = current.parentElement; } return null; } /** * Get the element path (breadcrumb) from document to element */ export function getElementPath(element: HTMLElement): string[] { const path: string[] = []; let current: HTMLElement | null = element; while (current && current.tagName !== 'HTML') { let selector = current.tagName.toLowerCase(); if (current.id) { selector += `#${current.id}`; } else if (current.className && typeof current.className === 'string') { const classes = current.className.trim().split(/\s+/).slice(0, 2); if (classes.length > 0 && classes[0]) { selector += `.${classes.join('.')}`; } } path.unshift(selector); current = current.parentElement; } return path; } /** * Check if an element is editable (contenteditable or input/textarea) */ export function isEditableElement(element: HTMLElement): boolean { return ( element.isContentEditable || element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT' ); } /** * Get the text content of an element (for display in UI) */ export function getElementTextPreview(element: HTMLElement, maxLength: number = 30): string { const text = element.textContent?.trim() || ''; if (text.length <= maxLength) { return text; } return text.substring(0, maxLength) + '...'; }