/** * Overlay Utility * * Handles drawing selection overlays on a canvas element that sits above the iframe. * Provides: * - Blue selection box around selected element * - Resize handles at corners * - Element label showing tag name * - Hover highlight effect */ import { SelectedElement, OverlayConfig } from '../types/editor'; // Default overlay configuration export const DEFAULT_OVERLAY_CONFIG: OverlayConfig = { strokeColor: '#2563eb', // Blue-600 strokeWidth: 2, fillColor: 'rgba(37, 99, 235, 0.1)', // Blue with transparency handleSize: 8, handleColor: '#2563eb', labelBackground: '#2563eb', labelColor: '#ffffff', labelFont: '11px system-ui, -apple-system, sans-serif' }; // Hover overlay config (lighter) export const HOVER_OVERLAY_CONFIG: OverlayConfig = { strokeColor: '#60a5fa', // Blue-400 strokeWidth: 1, fillColor: 'rgba(96, 165, 250, 0.05)', handleSize: 0, // No handles on hover handleColor: '#60a5fa', labelBackground: '#60a5fa', labelColor: '#ffffff', labelFont: '10px system-ui, -apple-system, sans-serif' }; /** * Clears the entire canvas */ export function clearOverlay(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement): void { ctx.clearRect(0, 0, canvas.width, canvas.height); } /** * Draws the selection overlay for a selected element */ export function drawSelectionOverlay( ctx: CanvasRenderingContext2D, element: SelectedElement, iframeOffset: { x: number; y: number }, config: OverlayConfig = DEFAULT_OVERLAY_CONFIG ): void { const { rect, tagName } = element; // Calculate position relative to the canvas (accounting for iframe position) const x = rect.left + iframeOffset.x; const y = rect.top + iframeOffset.y; const width = rect.width; const height = rect.height; // Draw fill ctx.fillStyle = config.fillColor; ctx.fillRect(x, y, width, height); // Draw border ctx.strokeStyle = config.strokeColor; ctx.lineWidth = config.strokeWidth; ctx.setLineDash([]); ctx.strokeRect(x, y, width, height); // Draw resize handles if (config.handleSize > 0) { drawResizeHandles(ctx, x, y, width, height, config); } // Draw element label drawElementLabel(ctx, tagName, x, y, config); } /** * Draws the hover overlay (lighter, no handles) */ export function drawHoverOverlay( ctx: CanvasRenderingContext2D, element: SelectedElement, iframeOffset: { x: number; y: number }, config: OverlayConfig = HOVER_OVERLAY_CONFIG ): void { const { rect } = element; const x = rect.left + iframeOffset.x; const y = rect.top + iframeOffset.y; const width = rect.width; const height = rect.height; // Draw dashed border for hover ctx.strokeStyle = config.strokeColor; ctx.lineWidth = config.strokeWidth; ctx.setLineDash([4, 4]); ctx.strokeRect(x, y, width, height); ctx.setLineDash([]); } /** * Draws resize handles at corners and edges */ function drawResizeHandles( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, config: OverlayConfig ): void { const { handleSize, handleColor } = config; const halfHandle = handleSize / 2; ctx.fillStyle = handleColor; // Corner handles const corners = [ { x: x - halfHandle, y: y - halfHandle }, // Top-left { x: x + width - halfHandle, y: y - halfHandle }, // Top-right { x: x - halfHandle, y: y + height - halfHandle }, // Bottom-left { x: x + width - halfHandle, y: y + height - halfHandle } // Bottom-right ]; // Edge handles (center of each edge) const edges = [ { x: x + width / 2 - halfHandle, y: y - halfHandle }, // Top { x: x + width / 2 - halfHandle, y: y + height - halfHandle }, // Bottom { x: x - halfHandle, y: y + height / 2 - halfHandle }, // Left { x: x + width - halfHandle, y: y + height / 2 - halfHandle } // Right ]; // Draw corner handles as filled squares with white background corners.forEach(corner => { ctx.fillStyle = '#ffffff'; ctx.fillRect(corner.x - 1, corner.y - 1, handleSize + 2, handleSize + 2); ctx.fillStyle = handleColor; ctx.fillRect(corner.x, corner.y, handleSize, handleSize); }); // Draw edge handles (smaller, only if element is large enough) if (width > 50 && height > 50) { edges.forEach(edge => { ctx.fillStyle = '#ffffff'; ctx.fillRect(edge.x - 1, edge.y - 1, handleSize + 2, handleSize + 2); ctx.fillStyle = handleColor; ctx.fillRect(edge.x, edge.y, handleSize, handleSize); }); } } /** * Draws the element label above the selection box */ function drawElementLabel( ctx: CanvasRenderingContext2D, tagName: string, x: number, y: number, config: OverlayConfig ): void { const label = tagName.toLowerCase(); const padding = { x: 6, y: 3 }; ctx.font = config.labelFont; const metrics = ctx.measureText(label); const labelWidth = metrics.width + padding.x * 2; const labelHeight = 18; // Position label above the element, or inside if at top of viewport const labelY = y > labelHeight + 4 ? y - labelHeight - 2 : y + 2; const labelX = x; // Draw label background with rounded corners ctx.fillStyle = config.labelBackground; roundRect(ctx, labelX, labelY, labelWidth, labelHeight, 3); ctx.fill(); // Draw label text ctx.fillStyle = config.labelColor; ctx.textBaseline = 'middle'; ctx.fillText(label, labelX + padding.x, labelY + labelHeight / 2); } /** * Helper to draw rounded rectangles */ function roundRect( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ): void { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } /** * Draws spacing guides (margin/padding visualization) */ export function drawSpacingGuides( ctx: CanvasRenderingContext2D, element: SelectedElement, iframeOffset: { x: number; y: number } ): void { const { rect, computedStyles } = element; const x = rect.left + iframeOffset.x; const y = rect.top + iframeOffset.y; const width = rect.width; const height = rect.height; // Parse margin values const marginTop = parseFloat(computedStyles.marginTop) || 0; const marginRight = parseFloat(computedStyles.marginRight) || 0; const marginBottom = parseFloat(computedStyles.marginBottom) || 0; const marginLeft = parseFloat(computedStyles.marginLeft) || 0; // Draw margin areas (orange/salmon color) ctx.fillStyle = 'rgba(251, 191, 36, 0.3)'; // Amber-400 with transparency // Top margin if (marginTop > 0) { ctx.fillRect(x, y - marginTop, width, marginTop); } // Right margin if (marginRight > 0) { ctx.fillRect(x + width, y, marginRight, height); } // Bottom margin if (marginBottom > 0) { ctx.fillRect(x, y + height, width, marginBottom); } // Left margin if (marginLeft > 0) { ctx.fillRect(x - marginLeft, y, marginLeft, height); } // Parse padding values const paddingTop = parseFloat(computedStyles.paddingTop) || 0; const paddingRight = parseFloat(computedStyles.paddingRight) || 0; const paddingBottom = parseFloat(computedStyles.paddingBottom) || 0; const paddingLeft = parseFloat(computedStyles.paddingLeft) || 0; // Draw padding areas (green color) ctx.fillStyle = 'rgba(52, 211, 153, 0.3)'; // Emerald-400 with transparency // Top padding if (paddingTop > 0) { ctx.fillRect(x, y, width, paddingTop); } // Right padding if (paddingRight > 0) { ctx.fillRect(x + width - paddingRight, y, paddingRight, height); } // Bottom padding if (paddingBottom > 0) { ctx.fillRect(x, y + height - paddingBottom, width, paddingBottom); } // Left padding if (paddingLeft > 0) { ctx.fillRect(x, y, paddingLeft, height); } } /** * Draws dimension labels (width x height) */ export function drawDimensionLabels( ctx: CanvasRenderingContext2D, element: SelectedElement, iframeOffset: { x: number; y: number } ): void { const { rect } = element; const x = rect.left + iframeOffset.x; const y = rect.top + iframeOffset.y; const width = rect.width; const height = rect.height; // Draw width label at bottom const widthLabel = `${Math.round(width)}px`; ctx.font = '10px system-ui, -apple-system, sans-serif'; const widthMetrics = ctx.measureText(widthLabel); ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect( x + width / 2 - widthMetrics.width / 2 - 4, y + height + 4, widthMetrics.width + 8, 14 ); ctx.fillStyle = '#ffffff'; ctx.textBaseline = 'middle'; ctx.fillText(widthLabel, x + width / 2 - widthMetrics.width / 2, y + height + 11); // Draw height label at right const heightLabel = `${Math.round(height)}px`; const heightMetrics = ctx.measureText(heightLabel); ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect( x + width + 4, y + height / 2 - 7, heightMetrics.width + 8, 14 ); ctx.fillStyle = '#ffffff'; ctx.fillText(heightLabel, x + width + 8, y + height / 2); } /** * Full overlay render function - combines selection, hover, and guides */ export function renderOverlay( ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, selectedElement: SelectedElement | null, hoveredElement: SelectedElement | null, iframeOffset: { x: number; y: number }, options: { showSpacing?: boolean; showDimensions?: boolean; } = {} ): void { // Clear previous frame clearOverlay(ctx, canvas); // Draw hover overlay (if different from selected) if (hoveredElement && (!selectedElement || hoveredElement.node !== selectedElement.node)) { drawHoverOverlay(ctx, hoveredElement, iframeOffset); } // Draw selection overlay if (selectedElement) { // Draw spacing guides first (underneath selection) if (options.showSpacing) { drawSpacingGuides(ctx, selectedElement, iframeOffset); } // Draw main selection drawSelectionOverlay(ctx, selectedElement, iframeOffset); // Draw dimension labels if (options.showDimensions) { drawDimensionLabels(ctx, selectedElement, iframeOffset); } } } /** * Gets the resize handle at a specific point (for cursor changes) */ export type ResizeHandle = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se' | null; export function getResizeHandleAtPoint( x: number, y: number, element: SelectedElement, iframeOffset: { x: number; y: number }, handleSize: number = DEFAULT_OVERLAY_CONFIG.handleSize ): ResizeHandle { const { rect } = element; const ex = rect.left + iframeOffset.x; const ey = rect.top + iframeOffset.y; const ew = rect.width; const eh = rect.height; const tolerance = handleSize + 4; // Check corners if (Math.abs(x - ex) < tolerance && Math.abs(y - ey) < tolerance) return 'nw'; if (Math.abs(x - (ex + ew)) < tolerance && Math.abs(y - ey) < tolerance) return 'ne'; if (Math.abs(x - ex) < tolerance && Math.abs(y - (ey + eh)) < tolerance) return 'sw'; if (Math.abs(x - (ex + ew)) < tolerance && Math.abs(y - (ey + eh)) < tolerance) return 'se'; // Check edges if (Math.abs(x - (ex + ew / 2)) < tolerance && Math.abs(y - ey) < tolerance) return 'n'; if (Math.abs(x - (ex + ew / 2)) < tolerance && Math.abs(y - (ey + eh)) < tolerance) return 's'; if (Math.abs(x - ex) < tolerance && Math.abs(y - (ey + eh / 2)) < tolerance) return 'w'; if (Math.abs(x - (ex + ew)) < tolerance && Math.abs(y - (ey + eh / 2)) < tolerance) return 'e'; return null; } /** * Gets the cursor style for a resize handle */ export function getCursorForHandle(handle: ResizeHandle): string { switch (handle) { case 'nw': case 'se': return 'nwse-resize'; case 'ne': case 'sw': return 'nesw-resize'; case 'n': case 's': return 'ns-resize'; case 'e': case 'w': return 'ew-resize'; default: return 'default'; } }