Spaces:
Running
Running
| 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<DeviceMode, { width: number; height: number }> = { | |
| 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<DeviceMode>('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<string | null>(null); | |
| const [dragOverPos, setDragOverPos] = useState<DropPosition>(null); | |
| const [draggedId, setDraggedId] = useState<string | null>(null); | |
| const canvasRef = useRef<HTMLDivElement>(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<string, { userId: string; username: string; color: string }[]>(); | |
| 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<HTMLDivElement>(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 ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => { setShowTagModal(false); setContextMenu(null); }}> | |
| <div className="bg-surface-900 border border-surface-700 rounded-lg shadow-xl p-4 w-64" onClick={e => e.stopPropagation()}> | |
| <h3 className="text-xs font-semibold text-surface-200 mb-2">Change Tag</h3> | |
| <input type="text" value={search} onChange={e => 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 /> | |
| <div className="max-h-48 overflow-y-auto space-y-0.5"> | |
| {filtered.map(tag => ( | |
| <button key={tag} onClick={() => handleTagChange(tag)} | |
| className={`w-full text-left px-2 py-1 rounded text-xs transition-colors ${tag === currentTag ? 'bg-primary-500/20 text-primary-300' : 'text-surface-300 hover:bg-surface-700'}`}> | |
| <{tag}> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex h-full"> | |
| {/* Toolbox */} | |
| <div className="w-56 panel border-r border-surface-700 flex flex-col py-2 overflow-y-auto"> | |
| <div className="px-3 pb-2 text-xs font-semibold text-surface-400 uppercase tracking-wider border-b border-surface-700 mb-2"> | |
| Elements | |
| </div> | |
| <div className="flex-1 overflow-y-auto px-2"> | |
| <Toolbox /> | |
| </div> | |
| </div> | |
| {/* Canvas */} | |
| <div className="flex-1 flex flex-col bg-surface-950/50"> | |
| <div className="h-10 glass border-b border-surface-700 flex items-center justify-between px-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="flex bg-surface-800 rounded-lg p-0.5 gap-0.5"> | |
| {(['desktop','tablet','mobile'] as DeviceMode[]).map(m => { | |
| const Ic = m === 'desktop' ? Monitor : m === 'tablet' ? Tablet : Smartphone; | |
| return ( | |
| <button key={m} onClick={() => setDeviceMode(m)} | |
| className={`p-1.5 rounded-md transition-all ${deviceMode === m ? 'bg-primary-600 text-white' : 'text-surface-400 hover:text-white'}`} title={m}> | |
| <Ic className="w-3.5 h-3.5" /> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <div className="w-px h-4 bg-surface-700" /> | |
| <button onClick={toggleSnapToGrid} | |
| className={`p-1.5 rounded-md ${snapToGrid ? 'text-primary-400' : 'text-surface-400 hover:text-white'}`} title="Snap to Grid"> | |
| <Grid3X3 className="w-3.5 h-3.5" /> | |
| </button> | |
| <button onClick={() => setShowGuides(!showGuides)} | |
| className={`p-1.5 rounded-md ${showGuides ? 'text-primary-400' : 'text-surface-400 hover:text-white'}`} title="Guides"> | |
| {showGuides ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} | |
| </button> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-surface-500">{(visualElements || []).length} elements</span> | |
| </div> | |
| </div> | |
| <div className="flex-1 overflow-auto p-8 flex items-start justify-center bg-surface-950" | |
| style={{ | |
| backgroundImage: snapToGrid ? 'radial-gradient(circle, rgba(148,163,184,0.1) 1px, transparent 1px)' : 'none', | |
| backgroundSize: snapToGrid ? '20px 20px' : 'auto', | |
| }}> | |
| <div ref={canvasRef} | |
| className="bg-white rounded-lg shadow-2xl transition-all duration-300 overflow-hidden relative" | |
| style={{ width: `${device.width}px`, minHeight: `${device.height}px`, maxWidth: '100%' }} | |
| onDrop={handleCanvasDrop} onDragOver={handleDragOver} | |
| onClick={handleCanvasClick}> | |
| {(visualElements || []).length === 0 ? ( | |
| <div className="flex items-center justify-center h-full min-h-[400px]" style={{ color: '#94a3b8', fontSize: '14px' }}> | |
| <div className="text-center"> | |
| <Layout className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p>Drag elements from the toolbox</p> | |
| <p className="text-xs mt-1">or drag existing elements to reorder them</p> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="p-4"> | |
| {(visualElements || []).map((el) => ( | |
| <VisualElementRenderer key={el.id} element={el} | |
| selectedId={selectedElementId} onSelect={handleElementClick} | |
| onContextMenu={handleContextMenu} depth={0} | |
| dragOverId={dragOverPos ? dragOverId : null} | |
| dragOverPos={dragOverPos} | |
| onAddChild={handleAddChild} | |
| onDragStart={handleElementDragStart} | |
| onDrop={handleCanvasDrop} | |
| onDragOver={handleDragOver} | |
| onDragEnd={handleDragEnd} | |
| draggedId={draggedId} | |
| collaboratorHovers={collaboratorHoverMap.get(el.id) || []} /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Sidebar */} | |
| <div className="w-80 panel border-l border-surface-700 overflow-y-auto"> | |
| <div className="p-3 border-b border-surface-700"> | |
| <div className="flex gap-1 bg-surface-800 rounded-lg p-0.5"> | |
| <button onClick={() => setViewMode('design')} | |
| className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${viewMode === 'design' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>Properties</button> | |
| <button onClick={() => setViewMode('preview')} | |
| className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${viewMode === 'preview' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>Tree</button> | |
| </div> | |
| </div> | |
| {viewMode === 'design' ? ( | |
| <PropertyPanel element={selectedElement} /> | |
| ) : ( | |
| <ElementTree elements={visualElements || []} selectedId={selectedElementId} | |
| onSelect={setSelectedElement} onContextMenu={handleContextMenu} /> | |
| )} | |
| </div> | |
| {/* Context Menu */} | |
| {contextMenu && ( | |
| <div ref={cmRef} | |
| className="fixed z-50 bg-surface-900 border border-surface-700 rounded-lg shadow-xl py-1 min-w-[160px]" | |
| style={{ left: contextMenu.x, top: contextMenu.y }}> | |
| <button onClick={cmDuplicate} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700"><Copy className="w-3.5 h-3.5" /> Duplicate</button> | |
| <button onClick={cmDelete} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-surface-700"><Trash2 className="w-3.5 h-3.5" /> Delete</button> | |
| <div className="h-px bg-surface-700 my-1" /> | |
| <button onClick={cmChangeTag} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700"><Code className="w-3.5 h-3.5" /> Change Tag</button> | |
| <button onClick={cmAddChild} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700"><Plus className="w-3.5 h-3.5" /> Add Child</button> | |
| <div className="h-px bg-surface-700 my-1" /> | |
| <button onClick={cmColorPick} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700"><Palette className="w-3.5 h-3.5" /> Text Color</button> | |
| <button onClick={cmBgColorPick} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700"><Palette className="w-3.5 h-3.5" /> Background Color</button> | |
| </div> | |
| )} | |
| {/* Color Picker */} | |
| {colorPicker && ( | |
| <div className="fixed z-50" style={{ left: colorPicker.x, top: colorPicker.y }}> | |
| <input type="color" value="#6366f1" autoFocus | |
| onChange={(e) => { | |
| 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)} /> | |
| </div> | |
| )} | |
| {showTagModal && contextMenu && ( | |
| <TagModal currentTag={findElementById(visualElements || [], contextMenu.elementId)?.tagName || 'div'} /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ===== 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<string, string> = { ...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 ( | |
| <div style={{ position: 'relative', display: 'inline-block' }}> | |
| <Tag {...attribs} style={style} className={element.classes.join(' ')} | |
| draggable | |
| onDragStart={(e: React.DragEvent) => 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 && ( | |
| <div className="absolute top-0 right-0 flex -space-x-1"> | |
| {collaboratorHovers.map((c) => ( | |
| <div key={c.userId} className="w-3.5 h-3.5 rounded-full flex items-center justify-center text-[6px] font-bold text-white" | |
| style={{ backgroundColor: c.color }} title={c.username}> | |
| {c.username.charAt(0).toUpperCase()} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div style={{ position: 'relative' }}> | |
| <Tag {...attribs} style={style} className={element.classes.join(' ')} | |
| draggable | |
| onDragStart={(e: React.DragEvent) => 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 && ( | |
| <div className="absolute top-0 right-0 flex -space-x-1 z-10"> | |
| {collaboratorHovers.map((c) => ( | |
| <div key={c.userId} className="w-3.5 h-3.5 rounded-full flex items-center justify-center text-[6px] font-bold text-white" | |
| style={{ backgroundColor: c.color }} title={c.username}> | |
| {c.username.charAt(0).toUpperCase()} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {element.props?.textContent || ''} | |
| {(element.children || []).map((child) => ( | |
| <VisualElementRenderer key={child.id} element={child} | |
| selectedId={selectedId} onSelect={onSelect} onContextMenu={onContextMenu} | |
| depth={depth + 1} dragOverId={dragOverId} dragOverPos={dragOverPos} | |
| onAddChild={onAddChild} onDragStart={onDragStart} | |
| onDrop={onDrop} onDragOver={onDragOver} | |
| onDragEnd={onDragEnd} draggedId={draggedId} | |
| collaboratorHovers={(window as any).__wsCollaboratorHoverMap?.get(child.id) || []} /> | |
| ))} | |
| </Tag> | |
| {isVoidTag ? null : ( | |
| <button onClick={(e) => { e.stopPropagation(); onAddChild(element.id); }} | |
| className="absolute -bottom-3 left-1/2 -translate-x-1/2 w-6 h-6 flex items-center justify-center rounded-full | |
| bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-all opacity-0 hover:opacity-100 z-10" | |
| title="Add child element"> | |
| <Plus className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |