import React, { useRef, useState, useEffect, useCallback, useMemo, memo } from 'react'; import { Stage, Layer, Rect, Line, Text, Group, Path, Circle } from 'react-konva'; import type Konva from 'konva'; import { useCanvasStore, createCodeElement, createTextElement, createArrowElement } from '../store/canvasStore'; import CodeBlock from './elements/CodeBlock'; import TextBlock from './elements/TextBlock'; import Arrow from './elements/Arrow'; import { SOCIAL_ICON_PATHS, SOCIAL_PLATFORMS_CONFIG } from './elements/SocialIcons'; import type { CodeElement, TextElement, ArrowElement } from '../types'; interface CanvasProps { stageRef: React.RefObject; } const Canvas: React.FC = ({ stageRef }) => { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight - 120 }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const { snap, zoom, showGrid, tool, selectedElementId, selectElement, addElement, updateElement, setZoom, } = useCanvasStore(); const { width, height } = snap.meta; const { background } = snap; const [brandingAvatar, setBrandingAvatar] = useState(null); // Handle resize useEffect(() => { const updateDimensions = () => { if (containerRef.current) { setDimensions({ width: containerRef.current.offsetWidth, height: containerRef.current.offsetHeight, }); } }; updateDimensions(); window.addEventListener('resize', updateDimensions); return () => window.removeEventListener('resize', updateDimensions); }, []); useEffect(() => { const url = background.branding?.avatarUrl || ''; if (!url) { setBrandingAvatar(null); return; } const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => setBrandingAvatar(img); img.onerror = () => setBrandingAvatar(null); img.src = url; return () => { img.onload = null; img.onerror = null; }; }, [background.branding?.avatarUrl]); // Calculate stage position to center the canvas const getStagePosition = useCallback(() => { const scaledWidth = width * zoom; const scaledHeight = height * zoom; return { x: Math.max(20, (dimensions.width - scaledWidth) / 2) + panOffset.x, y: Math.max(20, (dimensions.height - scaledHeight) / 2) + panOffset.y, }; }, [width, height, zoom, dimensions, panOffset]); // Reset pan offset when zoom changes significantly or canvas is re-centered const resetPan = useCallback(() => { setPanOffset({ x: 0, y: 0 }); }, []); const handleStageClick = (e: Konva.KonvaEventObject) => { const clickedOnEmpty = e.target === e.target.getStage() || e.target.name() === 'background'; if (clickedOnEmpty) { const stage = e.target.getStage(); if (!stage) return; const pos = stage.getPointerPosition(); if (!pos) return; // Convert screen position to canvas position const stagePos = getStagePosition(); const canvasX = (pos.x - stagePos.x) / zoom; const canvasY = (pos.y - stagePos.y) / zoom; if (tool === 'code') { addElement(createCodeElement(canvasX - 300, canvasY - 150)); } else if (tool === 'text') { addElement(createTextElement(canvasX, canvasY)); } else if (tool === 'arrow') { addElement(createArrowElement(canvasX, canvasY)); } else { selectElement(null); } } }; // Background gradient const renderBackground = () => { if (background.type === 'gradient') { return ( ); } return ( ); }; // Grid overlay - memoized for performance const gridLines = useMemo(() => { if (!showGrid) return null; const gridSize = 50; const lines = []; // Vertical lines for (let i = 0; i <= width; i += gridSize) { lines.push( ); } // Horizontal lines for (let i = 0; i <= height; i += gridSize) { lines.push( ); } return <>{lines}; }, [showGrid, width, height]); // Brand strip - memoized const brandStripElement = useMemo(() => { const brandStrip = background.brandStrip; if (!brandStrip?.enabled) return null; const stripHeight = brandStrip.height || 60; const stripY = brandStrip.position === 'top' ? 0 : height - stripHeight; return ( <> {brandStrip.text && ( )} ); }, [background.brandStrip, width, height]); // Branding watermark - memoized const brandingElement = useMemo(() => { const branding = background.branding; if (!branding?.enabled) return null; const padding = branding.padding || 24; const fontSize = branding.fontSize || 14; const lineHeight = fontSize * 1.5; const iconSize = branding.socialIconSize || 20; const socialLayout = branding.socialLayout || 'horizontal'; const iconGap = 16; const iconTextGap = 6; // Gap between icon and its text const avatarSize = branding.avatarSize || 56; const avatarGap = 14; const hasAvatar = Boolean(branding.showAvatar && branding.avatarUrl && brandingAvatar); // Build branding text lines const lines: string[] = []; if (branding.showName && branding.name) { lines.push(branding.name); } if (branding.showWebsite && branding.website) { lines.push(branding.website); } // Get active social platforms with their values const activeSocialPlatforms = branding.showSocial && branding.social ? SOCIAL_PLATFORMS_CONFIG.filter(p => branding.social[p.key as keyof typeof branding.social]) .map(p => ({ ...p, value: branding.social[p.key as keyof typeof branding.social] || '' })) : []; const hasTextContent = lines.length > 0; const hasSocialIcons = activeSocialPlatforms.length > 0; if (!hasTextContent && !hasSocialIcons && !hasAvatar) return null; const textHeight = lines.length * lineHeight; // Calculate social section dimensions (icon + text for each platform) const socialItemHeight = Math.max(iconSize, fontSize); const socialHeight = socialLayout === 'vertical' ? activeSocialPlatforms.length * socialItemHeight + (activeSocialPlatforms.length - 1) * (iconGap - 4) : socialItemHeight; // Calculate total content height const contentHeight = (hasTextContent || hasSocialIcons) ? textHeight + (hasTextContent && hasSocialIcons ? 16 : 0) + (hasSocialIcons ? socialHeight : 0) : 0; const blockHeight = Math.max(contentHeight, hasAvatar ? avatarSize : 0); // Calculate position based on setting let x = padding; let y = padding; let align: 'left' | 'right' = 'left'; switch (branding.position) { case 'top-left': x = padding; y = padding; align = 'left'; break; case 'top-right': x = width - padding; y = padding; align = 'right'; break; case 'bottom-left': x = padding; y = height - padding - blockHeight; align = 'left'; break; case 'bottom-right': x = width - padding; y = height - padding - blockHeight; align = 'right'; break; } const totalHeight = blockHeight; const contentTop = contentHeight > 0 ? y + (totalHeight - contentHeight) / 2 : y; const avatarY = hasAvatar ? y + (totalHeight - avatarSize) / 2 : 0; const avatarCenterX = align === 'right' ? x - avatarSize / 2 : x + avatarSize / 2; const avatarCenterY = avatarY + avatarSize / 2; const avatarPatternScale = hasAvatar && brandingAvatar && brandingAvatar.width && brandingAvatar.height ? Math.max(avatarSize / brandingAvatar.width, avatarSize / brandingAvatar.height) : 1; const avatarOffset = hasAvatar ? avatarSize + avatarGap : 0; const textStartXBase = align === 'right' ? x - avatarOffset : x + avatarOffset; const textX = align === 'right' ? 0 : textStartXBase; const textWidth = align === 'right' ? Math.max(textStartXBase, 0) : Math.max(width - padding - textStartXBase, 0); // Calculate icon positions const iconScale = iconSize / 24; // SVG viewBox is 24x24 const socialStartY = contentTop + textHeight + (hasTextContent ? 16 : 0); const anchorX = textStartXBase; return ( {/* Avatar */} {hasAvatar && brandingAvatar && ( )} {/* Text content */} {hasTextContent && ( )} {/* Social icons with text */} {hasSocialIcons && activeSocialPlatforms.map((platform, index) => { const path = SOCIAL_ICON_PATHS[platform.key]; if (!path) return null; // Calculate position for this social item let itemX: number; let itemY: number; if (socialLayout === 'vertical') { itemY = socialStartY + index * (socialItemHeight + iconGap - 4); itemX = anchorX; } else { itemY = socialStartY; // For horizontal layout, we need to calculate cumulative width // This is simplified - for perfect alignment we'd need to measure text const prevItemsWidth = activeSocialPlatforms.slice(0, index).reduce((acc, p) => { const textWidthEstimate = (p.value.length * fontSize * 0.5); // Approximate text width return acc + iconSize + iconTextGap + textWidthEstimate + iconGap; }, 0); itemX = align === 'right' ? anchorX - prevItemsWidth : anchorX + prevItemsWidth; } // Icon vertical centering within item const iconY = itemY + (socialItemHeight - iconSize) / 2; // Text vertical centering const textY = itemY + (socialItemHeight - fontSize) / 2; if (align === 'right') { // For right alignment: text first, then icon const textWidthEstimate = platform.value.length * fontSize * 0.5; // Approximate return ( ); } else { // For left alignment: icon first, then text return ( ); } })} ); }, [background.branding, width, height, brandingAvatar]); const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]); // Handle wheel/pinch zoom and pan on canvas const handleWheel = useCallback((e: React.WheelEvent) => { // Check if it's a pinch zoom gesture (ctrlKey is true for pinch-to-zoom) if (e.ctrlKey || e.metaKey) { e.preventDefault(); e.stopPropagation(); const scaleBy = 1.05; const minZoom = 0.1; const maxZoom = 3; // Determine zoom direction const direction = e.deltaY > 0 ? -1 : 1; const newZoom = direction > 0 ? zoom * scaleBy : zoom / scaleBy; // Clamp zoom between min and max const clampedZoom = Math.min(Math.max(newZoom, minZoom), maxZoom); setZoom(clampedZoom); } else { // Two-finger scroll for panning e.preventDefault(); e.stopPropagation(); setPanOffset(prev => ({ x: prev.x - e.deltaX, y: prev.y - e.deltaY, })); } }, [zoom, setZoom]); // Prevent default browser zoom behavior on the container useEffect(() => { const container = containerRef.current; if (!container) return; const preventDefaultZoom = (e: WheelEvent) => { // Prevent default for both zoom (ctrl/meta) and pan (regular scroll) e.preventDefault(); }; // Use passive: false to allow preventDefault container.addEventListener('wheel', preventDefaultZoom, { passive: false }); return () => { container.removeEventListener('wheel', preventDefaultZoom); }; }, []); return (
{ // Double-click on empty area to reset pan if (e.target === containerRef.current) { resetPan(); } }} > {renderBackground()} {brandStripElement} {brandingElement} {gridLines} {snap.elements.map((element) => { if (!element.visible) return null; switch (element.type) { case 'code': return ( selectElement(element.id)} onChange={(updates: Partial) => updateElement(element.id, updates)} /> ); case 'text': return ( selectElement(element.id)} onChange={(updates: Partial) => updateElement(element.id, updates)} /> ); case 'arrow': return ( selectElement(element.id)} onChange={(updates: Partial) => updateElement(element.id, updates)} /> ); default: return null; } })}
); }; export default memo(Canvas);