RealBlocks / client /src /components /VisualEditor /VisualEditor.tsx
SafeSight's picture
socket efficiency
ed88c5d
Raw
History Blame Contribute Delete
26.9 kB
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'}`}>
&lt;{tag}&gt;
</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>
);
}