import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useEditorStore } from '../../store/editorStore'; import { useCollaborationStore } from '../../store/collaborationStore'; import { VisualElement } from '../../types/blocks'; import PropertyPanel from './PropertyPanel'; import ElementTree from './ElementTree'; import Toolbox from './Toolbox'; import { Monitor, Tablet, Smartphone, Grid3X3, Eye, EyeOff, Trash2, Copy, Plus, Code, Palette, Layout } from 'lucide-react'; import { getColor } from '../Collaboration/CollaboratorAvatars'; import { getSocket } from '../../hooks/useWebSocket'; type DeviceMode = 'desktop' | 'tablet' | 'mobile'; type DropPosition = 'before' | 'after' | 'inside' | null; const DEVICE_SIZES: Record = { desktop: { width: 1280, height: 800 }, tablet: { width: 768, height: 1024 }, mobile: { width: 375, height: 667 }, }; function uuid() { return crypto.randomUUID?.() || Math.random().toString(36).slice(2, 11); } function createVisualElement(tagName: string, defaultProps: any): VisualElement { const id = uuid(); return { id, tagName, type: 'element', children: defaultProps?.children || [], props: Object.fromEntries(Object.entries(defaultProps || {}).filter(([k]) => k !== 'children' && k !== 'style')), styles: defaultProps?.style || {}, classes: [], attributes: {}, }; } function duplicateElement(el: VisualElement): VisualElement { return { ...el, id: uuid(), children: (el.children || []).map(duplicateElement) }; } // Tree helpers function findElementById(elements: VisualElement[] | undefined, id: string): VisualElement | null { if (!elements) return null; for (const el of elements) { if (el.id === id) return el; if (el.children) { const f = findElementById(el.children, id); if (f) return f; } } return null; } function removeFromTree(elements: VisualElement[], id: string): { result: VisualElement[]; removed: VisualElement | null } { let removed: VisualElement | null = null; const filter = (list: VisualElement[]): VisualElement[] => list.filter(el => { if (el.id === id) { removed = el; return false; } if (el.children) el.children = filter(el.children); return true; }); return { result: filter([...elements]), removed }; } function insertAt(elements: VisualElement[], targetId: string, newEl: VisualElement, pos: DropPosition): VisualElement[] { if (pos === 'inside') { return elements.map(el => { if (el.id === targetId) return { ...el, children: [...(el.children || []), newEl] }; if (el.children) return { ...el, children: insertAt(el.children, targetId, newEl, pos) }; return el; }); } const idx = elements.findIndex(el => el.id === targetId); if (idx === -1) { return elements.map(el => { if (el.children) return { ...el, children: insertAt(el.children, targetId, newEl, pos) }; return el; }); } const arr = [...elements]; arr.splice(pos === 'before' ? idx : idx + 1, 0, newEl); return arr; } export default function VisualEditor() { const { visualElements, setVisualElements, selectedElementId, setSelectedElement, viewMode, setViewMode, snapToGrid, toggleSnapToGrid, addVisualElement, updateVisualElement, removeVisualElement, syncVisualElementsToRegistry, activeFileId, } = useEditorStore(); const [deviceMode, setDeviceMode] = useState('desktop'); const [showGuides, setShowGuides] = useState(true); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; elementId: string } | null>(null); const [colorPicker, setColorPicker] = useState<{ elementId: string; property: string; x: number; y: number } | null>(null); const [showTagModal, setShowTagModal] = useState(false); const [dragOverId, setDragOverId] = useState(null); const [dragOverPos, setDragOverPos] = useState(null); const [draggedId, setDraggedId] = useState(null); const canvasRef = useRef(null); // Set up global WS hover emit (throttled to prevent flooding) useEffect(() => { let lastHoverEmit = 0; let lastHoverId: string | null = null; (window as any).__wsElementHoverEmit = (elementId: string | null) => { const now = Date.now(); if (elementId === lastHoverId && now - lastHoverEmit < 60) return; lastHoverEmit = now; lastHoverId = elementId; const socket = getSocket(); const projectId = (window as any).__projectId; if (socket?.connected && projectId) { socket.emit('visual_element_hover', { projectId, elementId }); } }; (window as any).__wsElementHover = (elementId: string | null) => { const fn = (window as any).__wsElementHoverEmit; if (fn) fn(elementId); }; return () => { (window as any).__wsElementHover = undefined; (window as any).__wsElementHoverEmit = undefined; }; }, []); // Collaborator element hovers for overlay const collaborators = useCollaborationStore((s) => s.collaborators); const collaboratorHoverMap = useMemo(() => { const map = new Map(); collaborators.forEach((c) => { if (c.selectedElementId) { const arr = map.get(c.selectedElementId) || []; arr.push({ userId: c.userId, username: c.username, color: getColor(c.userId) }); map.set(c.selectedElementId, arr); } }); // Set global for child renderers (window as any).__wsCollaboratorHoverMap = map; return map; }, [collaborators]); const cmRef = useRef(null); useEffect(() => { const handler = (e: MouseEvent) => { if (cmRef.current && !cmRef.current.contains(e.target as Node)) setContextMenu(null); }; if (contextMenu) document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [contextMenu]); // Combined drop handler: toolbox elements and reorder const handleCanvasDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const toolboxData = e.dataTransfer.getData('application/visual-element'); if (toolboxData) { const preset = JSON.parse(toolboxData); const element = createVisualElement(preset.tagName, preset.defaultProps); const target = (e.target as HTMLElement).closest('[data-element-id]'); const parentId = target?.getAttribute('data-element-id') || undefined; addVisualElement(element, parentId); return; } // Reorder const dragId = draggedId; const targetId = dragOverId; setDraggedId(null); setDragOverId(null); setDragOverPos(null); if (!dragId || !targetId || dragId === targetId) return; const { result: afterRemove, removed } = removeFromTree(visualElements || [], dragId); if (!removed) { setVisualElements(afterRemove); if (activeFileId) syncVisualElementsToRegistry(activeFileId); return; } const newTree = insertAt(afterRemove, targetId, removed, dragOverPos); setVisualElements(newTree); if (activeFileId) syncVisualElementsToRegistry(activeFileId); }, [draggedId, dragOverId, dragOverPos, visualElements, setVisualElements, addVisualElement, syncVisualElementsToRegistry, activeFileId]); const handleElementDragStart = useCallback((e: React.DragEvent, elementId: string) => { e.stopPropagation(); setDraggedId(elementId); e.dataTransfer.setData('text/plain', elementId); e.dataTransfer.effectAllowed = 'move'; if (e.currentTarget instanceof HTMLElement) { const rect = e.currentTarget.getBoundingClientRect(); e.dataTransfer.setDragImage(e.currentTarget, e.clientX - rect.left, e.clientY - rect.top); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); const targetEl = (e.target as HTMLElement).closest('[data-element-id]'); const targetId = targetEl?.getAttribute('data-element-id') || null; if (!targetId || targetId === draggedId) { setDragOverId(null); setDragOverPos(null); return; } setDragOverId(targetId); if (targetEl) { const rect = targetEl.getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; const isContainer = !['INPUT','IMG','BR','HR'].includes(targetEl.tagName); if (y < h * 0.25) setDragOverPos('before'); else if (y > h * 0.75) setDragOverPos('after'); else if (isContainer) setDragOverPos('inside'); else setDragOverPos('after'); } }, [draggedId]); const handleDragEnd = useCallback(() => { setDraggedId(null); setDragOverId(null); setDragOverPos(null); }, []); const handleElementClick = useCallback((e: React.MouseEvent, elementId: string) => { e.stopPropagation(); setContextMenu(null); setColorPicker(null); setSelectedElement(elementId); }, [setSelectedElement]); const handleCanvasClick = useCallback(() => { setSelectedElement(null); setContextMenu(null); setColorPicker(null); }, [setSelectedElement]); const handleContextMenu = useCallback((e: React.MouseEvent, elementId: string) => { e.preventDefault(); e.stopPropagation(); setSelectedElement(elementId); setContextMenu({ x: e.clientX, y: e.clientY, elementId }); }, [setSelectedElement]); const selectedElement = selectedElementId ? findElementById(visualElements || [], selectedElementId) : null; const device = DEVICE_SIZES[deviceMode]; // Context menu actions const cmDuplicate = useCallback(() => { if (!contextMenu) return; const el = findElementById(visualElements || [], contextMenu.elementId); if (el) addVisualElement(duplicateElement(el)); setContextMenu(null); }, [contextMenu, visualElements, addVisualElement]); const cmDelete = useCallback(() => { if (contextMenu) { removeVisualElement(contextMenu.elementId); setContextMenu(null); } }, [contextMenu, removeVisualElement]); const cmChangeTag = useCallback(() => { setShowTagModal(true); }, []); const handleTagChange = useCallback((newTag: string) => { if (!contextMenu) return; updateVisualElement(contextMenu.elementId, { tagName: newTag }); setShowTagModal(false); setContextMenu(null); }, [contextMenu, updateVisualElement]); const cmAddChild = useCallback(() => { if (!contextMenu) return; const child = createVisualElement('div', { style: { padding: '12px', border: '1px dashed #94a3b8', borderRadius: '4px' } }); addVisualElement(child, contextMenu.elementId); setContextMenu(null); }, [contextMenu, addVisualElement]); const handleAddChild = useCallback((parentId: string) => { addVisualElement(createVisualElement('div', { style: { padding: '12px', border: '1px dashed #6366f1', borderRadius: '4px' } }), parentId); }, [addVisualElement]); const cmColorPick = useCallback(() => { if (!contextMenu) return; setColorPicker({ elementId: contextMenu.elementId, property: 'color', x: contextMenu.x, y: contextMenu.y }); setContextMenu(null); }, [contextMenu]); const cmBgColorPick = useCallback(() => { if (!contextMenu) return; setColorPicker({ elementId: contextMenu.elementId, property: 'backgroundColor', x: contextMenu.x, y: contextMenu.y }); setContextMenu(null); }, [contextMenu]); const ALL_TAGS = ['div','span','p','h1','h2','h3','h4','h5','h6','a','button','input','img','form','section','header','footer','nav','main','aside','ul','ol','li','table','label','textarea','select','article','blockquote','pre','code','figure','figcaption','details','summary','dialog','menu','strong','em','b','i','u','s','mark','small','sub','sup','cite','time','address','video','audio','canvas','iframe','fieldset','legend','progress','meter','dl','dt','dd']; function TagModal({ currentTag }: { currentTag: string }) { const [search, setSearch] = useState(''); const filtered = search ? ALL_TAGS.filter(t => t.includes(search.toLowerCase())) : ALL_TAGS; return (
{ setShowTagModal(false); setContextMenu(null); }}>
e.stopPropagation()}>

Change Tag

setSearch(e.target.value)} className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 mb-2" placeholder="Search tags..." autoFocus />
{filtered.map(tag => ( ))}
); } return (
{/* Toolbox */}
Elements
{/* Canvas */}
{(['desktop','tablet','mobile'] as DeviceMode[]).map(m => { const Ic = m === 'desktop' ? Monitor : m === 'tablet' ? Tablet : Smartphone; return ( ); })}
{(visualElements || []).length} elements
{(visualElements || []).length === 0 ? (

Drag elements from the toolbox

or drag existing elements to reorder them

) : (
{(visualElements || []).map((el) => ( ))}
)}
{/* Sidebar */}
{viewMode === 'design' ? ( ) : ( )}
{/* Context Menu */} {contextMenu && (
)} {/* Color Picker */} {colorPicker && (
{ updateVisualElement(colorPicker.elementId, { styles: { ...(findElementById(visualElements || [], colorPicker.elementId)?.styles || {}), [colorPicker.property]: e.target.value } }); setColorPicker(null); }} className="w-32 h-32 cursor-pointer border-0 rounded-lg shadow-xl" style={{ background: 'none' }} onBlur={() => setColorPicker(null)} />
)} {showTagModal && contextMenu && ( )}
); } // ===== VISUAL ELEMENT RENDERER ===== function VisualElementRenderer({ element, selectedId, onSelect, onContextMenu, depth, dragOverId, dragOverPos, onAddChild, onDragStart, onDrop, onDragOver, onDragEnd, draggedId, collaboratorHovers, }: { element: VisualElement; selectedId: string | null; onSelect: (e: React.MouseEvent, id: string) => void; onContextMenu: (e: React.MouseEvent, id: string) => void; depth: number; dragOverId: string | null; dragOverPos: DropPosition | null; onAddChild: (id: string) => void; onDragStart: (e: React.DragEvent, id: string) => void; onDrop: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; draggedId: string | null; collaboratorHovers: { userId: string; username: string; color: string }[]; }) { const isSelected = selectedId === element.id; const isDragTarget = dragOverId === element.id; const isDragged = draggedId === element.id; const Tag = element.tagName as keyof JSX.IntrinsicElements; // Build border/indicator for drop position let dropIndicator: React.CSSProperties = {}; if (isDragTarget && dragOverPos === 'before') { dropIndicator = { borderTop: '3px solid #6366f1' }; } else if (isDragTarget && dragOverPos === 'after') { dropIndicator = { borderBottom: '3px solid #6366f1' }; } else if (isDragTarget && dragOverPos === 'inside') { dropIndicator = { outline: '3px dashed #6366f1', outlineOffset: '2px' }; } const style: React.CSSProperties = { ...element.styles, ...dropIndicator, outline: isSelected && !isDragTarget ? '2px solid #6366f1' : isDragTarget && dragOverPos === 'inside' ? '3px dashed #6366f1' : collaboratorHovers.length > 0 ? `2px solid ${collaboratorHovers[0].color}` : 'none', outlineOffset: isSelected || collaboratorHovers.length > 0 ? '2px' : '0', position: 'relative', cursor: isDragged ? 'grabbing' : 'grab', transition: 'outline 0.1s ease, border 0.1s ease', opacity: isDragged ? 0.5 : 1, }; const attribs: Record = { ...element.attributes, 'data-element-id': element.id }; if (element.props?.src) attribs.src = element.props.src; if (element.props?.href) attribs.href = element.props.href; if (element.props?.alt) attribs.alt = element.props.alt; if (element.props?.placeholder) attribs.placeholder = element.props.placeholder; if (element.props?.type) attribs.type = element.props.type; if (element.props?.value) attribs.value = element.props.value; if (element.props?.name) attribs.name = element.props.name; if (element.props?.id) attribs.id = element.props.id; const isVoidTag = ['br','hr','img','input','link','meta','area','base','col','embed','source','track','wbr'].includes(element.tagName); if (isVoidTag) { return (
onDragStart(e, element.id)} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={onDragEnd} onClick={(e: React.MouseEvent) => onSelect(e, element.id)} onContextMenu={(e: React.MouseEvent) => onContextMenu(e, element.id)} onMouseEnter={() => (window as any).__wsElementHover?.(element.id)} onMouseLeave={() => (window as any).__wsElementHover?.(null)} /> {collaboratorHovers.length > 0 && (
{collaboratorHovers.map((c) => (
{c.username.charAt(0).toUpperCase()}
))}
)}
); } return (
onDragStart(e, element.id)} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={onDragEnd} onClick={(e: React.MouseEvent) => onSelect(e, element.id)} onContextMenu={(e: React.MouseEvent) => onContextMenu(e, element.id)} onMouseEnter={() => (window as any).__wsElementHover?.(element.id)} onMouseLeave={() => (window as any).__wsElementHover?.(null)}> {collaboratorHovers.length > 0 && (
{collaboratorHovers.map((c) => (
{c.username.charAt(0).toUpperCase()}
))}
)} {element.props?.textContent || ''} {(element.children || []).map((child) => ( ))}
{isVoidTag ? null : ( )}
); }