/** * Canvas Component * * Main visual editor canvas that renders an iframe with editable HTML content. * Features: * - Iframe for isolated HTML editing environment * - Overlay canvas for selection/hover visualization * - Integration with selector and overlay utilities * - Communication bridge with StylePanel * * Architecture: * ┌─────────────────────────────────────────────────────────┐ * │ Canvas Container │ * │ ┌──────────────────────────────────────────────────────┐│ * │ │ Overlay Canvas (absolute, pointer-events: none) ││ * │ │ - Draws selection boxes ││ * │ │ - Draws hover highlights ││ * │ │ - Draws resize handles ││ * │ └──────────────────────────────────────────────────────┘│ * │ ┌──────────────────────────────────────────────────────┐│ * │ │ Iframe (editable HTML document) ││ * │ │ - Receives click events via selector.ts ││ * │ │ - Contains the page being edited ││ * │ └──────────────────────────────────────────────────────┘│ * └─────────────────────────────────────────────────────────┘ */ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { SelectedElement } from '../types/editor'; import { setupIframeClickListener, updateElementRect, createSelectedElement } from '../utils/selector'; import { renderOverlay, clearOverlay } from '../utils/overlay'; interface CanvasProps { initialContent?: string; selectedElement: SelectedElement | null; onElementSelect: (element: SelectedElement | null) => void; showSpacing?: boolean; showDimensions?: boolean; deviceMode?: 'desktop' | 'tablet' | 'mobile'; } // Default HTML content for the iframe - blank canvas ready for sections const DEFAULT_CONTENT = ` My Website
📦
Drag sections or elements here
or click elements in the left panel
`; // Canvas container styles const canvasStyles: { [key: string]: React.CSSProperties } = { wrapper: { position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }, canvasArea: { position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }, iframe: { width: '100%', height: '100%', border: 'none', display: 'block', background: '#fff' }, overlay: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 10 } }; export const Canvas: React.FC = ({ initialContent = DEFAULT_CONTENT, selectedElement, onElementSelect, showSpacing = false, showDimensions = true, deviceMode = 'desktop' }) => { // Refs const containerRef = useRef(null); const canvasAreaRef = useRef(null); const iframeRef = useRef(null); const overlayRef = useRef(null); // State const [hoveredElement, setHoveredElement] = useState(null); const [iframeLoaded, setIframeLoaded] = useState(false); const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); const [isDragOver, setIsDragOver] = useState(false); // Get iframe offset relative to overlay canvas const getIframeOffset = useCallback(() => { if (!iframeRef.current || !canvasAreaRef.current) { return { x: 0, y: 0 }; } const iframeRect = iframeRef.current.getBoundingClientRect(); const canvasAreaRect = canvasAreaRef.current.getBoundingClientRect(); return { x: iframeRect.left - canvasAreaRect.left, y: iframeRect.top - canvasAreaRect.top }; }, []); // Add element to the page const addElementToPage = useCallback((html: string) => { const iframeDoc = iframeRef.current?.contentDocument; if (!iframeDoc) return; const pageContent = iframeDoc.getElementById('page-content'); const dropZone = iframeDoc.getElementById('drop-zone'); if (pageContent) { // Remove drop zone if it exists and this is the first element if (dropZone) { dropZone.remove(); } // Create a wrapper div to parse the HTML const wrapper = iframeDoc.createElement('div'); wrapper.innerHTML = html; // Add each child element to the page while (wrapper.firstChild) { const element = wrapper.firstChild as HTMLElement; if (element.setAttribute) { element.setAttribute('data-editable', 'true'); } pageContent.appendChild(element); } } }, []); // Handle drag over the canvas const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); // Also update drop zone in iframe const iframeDoc = iframeRef.current?.contentDocument; const dropZone = iframeDoc?.getElementById('drop-zone'); if (dropZone) { dropZone.classList.add('drag-over'); } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { // Only trigger if leaving the container entirely if (!containerRef.current?.contains(e.relatedTarget as Node)) { setIsDragOver(false); const iframeDoc = iframeRef.current?.contentDocument; const dropZone = iframeDoc?.getElementById('drop-zone'); if (dropZone) { dropZone.classList.remove('drag-over'); } } }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const html = e.dataTransfer.getData('text/html'); if (html) { addElementToPage(html); } const iframeDoc = iframeRef.current?.contentDocument; const dropZone = iframeDoc?.getElementById('drop-zone'); if (dropZone) { dropZone.classList.remove('drag-over'); } }, [addElementToPage]); // Initialize iframe content useEffect(() => { const iframe = iframeRef.current; if (!iframe) return; const handleLoad = () => { const iframeDoc = iframe.contentDocument; const iframeWin = iframe.contentWindow; if (!iframeDoc || !iframeWin) return; // Write initial content iframeDoc.open(); iframeDoc.write(initialContent); iframeDoc.close(); setIframeLoaded(true); // Set up element selection listener const cleanup = setupIframeClickListener( iframeDoc, iframeWin, (element) => { onElementSelect(element); // Add editing class to body iframeDoc.body.classList.add('editing'); }, (element) => { setHoveredElement(element); } ); // Handle scroll inside iframe - update overlay const handleScroll = () => { if (selectedElement) { const updated = updateElementRect(selectedElement, iframeWin); onElementSelect(updated); } }; iframeDoc.addEventListener('scroll', handleScroll); return () => { cleanup(); iframeDoc.removeEventListener('scroll', handleScroll); }; }; iframe.addEventListener('load', handleLoad); // Trigger load if iframe is already loaded (e.g., cached) if (iframe.contentDocument?.readyState === 'complete') { handleLoad(); } return () => { iframe.removeEventListener('load', handleLoad); }; }, [initialContent, onElementSelect, selectedElement]); // Set up overlay canvas sizing useEffect(() => { const canvasArea = canvasAreaRef.current; const overlay = overlayRef.current; if (!canvasArea || !overlay) return; const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; // Set canvas size with device pixel ratio for crisp rendering const dpr = window.devicePixelRatio || 1; overlay.width = width * dpr; overlay.height = height * dpr; overlay.style.width = `${width}px`; overlay.style.height = `${height}px`; // Scale context for high DPI const ctx = overlay.getContext('2d'); if (ctx) { ctx.scale(dpr, dpr); } setViewportSize({ width: Math.round(width), height: Math.round(height) }); } }); resizeObserver.observe(canvasArea); return () => resizeObserver.disconnect(); }, []); // Render overlay when selection/hover changes useEffect(() => { const overlay = overlayRef.current; if (!overlay) return; const ctx = overlay.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; // Clear and redraw ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); const iframeOffset = getIframeOffset(); renderOverlay( ctx, overlay, selectedElement, hoveredElement, iframeOffset, { showSpacing, showDimensions } ); }, [selectedElement, hoveredElement, getIframeOffset, showSpacing, showDimensions]); // Handle click outside iframe to deselect const handleContainerClick = useCallback((e: React.MouseEvent) => { if (e.target === containerRef.current || e.target === canvasAreaRef.current) { onElementSelect(null); // Remove editing class from iframe body const iframeDoc = iframeRef.current?.contentDocument; if (iframeDoc) { iframeDoc.body.classList.remove('editing'); } } }, [onElementSelect]); // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Escape to deselect if (e.key === 'Escape' && selectedElement) { onElementSelect(null); const iframeDoc = iframeRef.current?.contentDocument; if (iframeDoc) { iframeDoc.body.classList.remove('editing'); } } // Delete to remove element (with confirmation in real implementation) if (e.key === 'Delete' && selectedElement) { // In a real implementation, you'd confirm before removing // selectedElement.node.remove(); // onElementSelect(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedElement, onElementSelect]); return (
{/* Drag overlay indicator */} {isDragOver && (
Drop to add
)} {/* Main canvas area */}
{/* Overlay canvas for selection visualization */} {/* Iframe with editable content */}