import { useRef, useEffect, useState } from 'react'; import { Stage, Layer, Transformer, Rect, Image as KonvaImage } from 'react-konva'; import { CanvasObject, CanvasSize, CanvasBgColor, TextObject } from '../../types/canvas.types'; import { getCanvasDimensions, sortByZIndex } from '../../utils/canvas.utils'; import CanvasObjectRenderer from './CanvasObject'; import Konva from 'konva'; import useImage from 'use-image'; // Helper function to get fontStyle string based on weight and italic function getFontStyle(bold: boolean, italic: boolean, fontWeight?: 'normal' | 'bold' | 'black'): string { const weight = fontWeight === 'black' ? '900' : bold ? 'bold' : 'normal'; const style = italic ? 'italic' : ''; return `${weight} ${style}`.trim(); } interface CanvasProps { canvasSize: CanvasSize; bgColor: CanvasBgColor; objects: CanvasObject[]; selectedIds: string[]; onSelect: (ids: string[]) => void; onObjectsChange: (objects: CanvasObject[]) => void; textCreationMode?: boolean; onTextCreate?: (x: number, y: number) => void; stageRef?: React.RefObject; transformerRef?: React.RefObject; scale?: number; } // Helper function to calculate cursor position from click coordinates function calculateCursorPosition(text: string, clickX: number, fontSize: number, fontFamily: string, bold: boolean, italic: boolean, fontWeight?: 'normal' | 'bold' | 'black'): number { if (!text || clickX <= 0) return 0; try { const tempText = new Konva.Text({ text: text, fontSize: fontSize, fontFamily: fontFamily, fontStyle: getFontStyle(bold, italic, fontWeight) }); // Find the closest character position let closestPos = 0; let closestDist = Infinity; for (let i = 0; i <= text.length; i++) { const substr = text.substring(0, i); tempText.text(substr); const width = tempText.width(); const dist = Math.abs(width - clickX); if (dist < closestDist) { closestDist = dist; closestPos = i; } } tempText.destroy(); return closestPos; } catch (error) { console.error('Error calculating cursor position:', error); return text.length; // Default to end } } export default function Canvas({ canvasSize, bgColor, objects, selectedIds, onSelect, onObjectsChange, textCreationMode = false, onTextCreate, stageRef: externalStageRef, transformerRef: externalTransformerRef, scale = 1 }: CanvasProps) { const internalStageRef = useRef(null); const stageRef = externalStageRef || internalStageRef; const internalTransformerRef = useRef(null); const transformerRef = externalTransformerRef || internalTransformerRef; const shapeRefs = useRef>(new Map()); const textareaRef = useRef(null); const cursorPositionRef = useRef(null); const [fontsLoaded, setFontsLoaded] = useState(false); const [selectionBox, setSelectionBox] = useState<{ x: number; y: number; width: number; height: number } | null>(null); const selectionStartRef = useRef<{ x: number; y: number } | null>(null); const transformEndTimeoutRef = useRef(null); const dragEndTimeoutRef = useRef(null); const dragStartPositions = useRef>(new Map()); const [hoveredId, setHoveredId] = useState(null); const [isCanvasHovered, setIsCanvasHovered] = useState(false); const [snapGuides, setSnapGuides] = useState<{ vertical: boolean; horizontal: boolean }>({ vertical: false, horizontal: false }); const dimensions = getCanvasDimensions(canvasSize); // Map canvas size to background image filename prefix const sizePrefix = canvasSize === '1200x675' ? 'twitter' : canvasSize === 'linkedin' ? 'LinkedIn' : 'HF'; // Get background image path based on bgColor and canvas size const getBackgroundImage = () => { if (bgColor === 'seriousLight') { return `/assets/backgrounds/bg_sLight_${sizePrefix}.png`; } else if (bgColor === 'light') { return `/assets/backgrounds/bg_Light_${sizePrefix}.png`; } else { return `/assets/backgrounds/bg_dark_${sizePrefix}.png`; } }; const backgroundImage = getBackgroundImage(); // Load background image with CORS enabled to allow canvas export const [bgImageElement] = useImage(backgroundImage, 'anonymous'); // Wait for fonts to load before rendering useEffect(() => { const loadFonts = async () => { if (document.fonts) { try { // Explicitly load all fonts used in layouts await Promise.all([ document.fonts.load('bold 100px Bison'), document.fonts.load('bold 100px Inter'), document.fonts.load('400 50px "IBM Plex Mono"'), ]); // Wait for all fonts to be ready await document.fonts.ready; // Add small delay to ensure fonts are fully applied await new Promise(resolve => setTimeout(resolve, 50)); setFontsLoaded(true); // Force canvas re-render after fonts load if (stageRef.current) { const layer = stageRef.current.getLayers()[0]; if (layer) { layer.batchDraw(); } } } catch (error) { console.error('Font loading error:', error); setFontsLoaded(true); } } else { // Fallback for browsers without Font Loading API setFontsLoaded(true); } }; loadFonts(); }, []); // Force re-render when objects change and fonts are loaded useEffect(() => { if (fontsLoaded && stageRef.current) { const layer = stageRef.current.getLayers()[0]; if (layer) { layer.batchDraw(); } } }, [objects, fontsLoaded]); // Get the editing text object const editingText = objects.find(obj => obj.type === 'text' && obj.isEditing) as TextObject | undefined; // Sort objects by zIndex for proper rendering order const sortedObjects = sortByZIndex(objects); // Update transformer when selection changes or objects change useEffect(() => { if (!transformerRef.current) return; const attemptAttachment = (attempt: number = 0) => { if (selectedIds.length > 0) { const nodes = selectedIds .map(id => shapeRefs.current.get(id)) .filter((node): node is Konva.Node => node !== undefined); if (nodes.length > 0) { transformerRef.current!.nodes(nodes); transformerRef.current!.show(); transformerRef.current!.forceUpdate(); const layer = transformerRef.current!.getLayer(); if (layer) { layer.batchDraw(); } } else if (attempt < 10) { // Retry up to 10 times with increasing delays (up to 500ms) const delay = Math.min(50 * (attempt + 1), 100); setTimeout(() => attemptAttachment(attempt + 1), delay); } } else { transformerRef.current!.nodes([]); } }; // Start attempting attachment attemptAttachment(0); }, [selectedIds, objects]); // Handle object selection const handleSelect = (id: string, shiftKey: boolean = false) => { if (shiftKey) { // Shift+Click: add/remove from selection if (selectedIds.includes(id)) { // Remove from selection onSelect(selectedIds.filter(selectedId => selectedId !== id)); } else { // Add to selection onSelect([...selectedIds, id]); } } else { // Normal click: select only this object onSelect([id]); } }; // Store initial drag position for shift constraint const dragStartPosRef = useRef<{ x: number; y: number } | null>(null); // Handle drag start to store initial position(s) const handleDragStart = (e: Konva.KonvaEventObject) => { const node = e.target; dragStartPosRef.current = { x: node.x(), y: node.y() }; // For multi-select, store start positions of ALL selected objects const isMultiSelect = selectedIds.length > 1; if (isMultiSelect) { dragStartPositions.current.clear(); selectedIds.forEach(id => { const objNode = shapeRefs.current.get(id); if (objNode) { dragStartPositions.current.set(id, { x: objNode.x(), y: objNode.y() }); } }); } }; // Handle drag move to constrain to horizontal/vertical when Shift is held const handleDragMove = (e: Konva.KonvaEventObject) => { const node = e.target; const shiftPressed = e.evt.shiftKey; // Check if multiple objects are selected const isMultiSelect = selectedIds.length > 1; // Shift key constraint - DISABLED for multi-select to prevent sticking // Only apply shift constraint for single object selection if (shiftPressed && !isMultiSelect && dragStartPosRef.current) { const startPos = dragStartPosRef.current; const currentX = node.x(); const currentY = node.y(); // Calculate movement deltas const deltaX = Math.abs(currentX - startPos.x); const deltaY = Math.abs(currentY - startPos.y); // Determine which axis to constrain const isHorizontal = deltaX > deltaY; if (isHorizontal) { // Horizontal movement - lock Y node.y(startPos.y); } else { // Vertical movement - lock X node.x(startPos.x); } } // Smart snapping to canvas center lines // When Shift is pressed, only snap along the unconstrained axis { const snapThreshold = 10; // pixels const centerX = dimensions.width / 2; const centerY = dimensions.height / 2; // Check if multiple objects are selected const isMultiSelect = selectedIds.length > 1; if (isMultiSelect) { // Calculate bounding box of all selected objects const selectedNodes = selectedIds .map(selectedId => shapeRefs.current.get(selectedId)) .filter((n): n is Konva.Node => n !== undefined); // Only do group snapping if ALL selected nodes are available // This prevents rare cases where some nodes aren't registered yet if (selectedNodes.length === selectedIds.length && selectedNodes.length > 1) { // Calculate the combined bounding box let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; selectedNodes.forEach(selectedNode => { const nodeWidth = selectedNode.width(); const nodeHeight = selectedNode.height(); const nodeX = selectedNode.x(); const nodeY = selectedNode.y(); const left = nodeX; const top = nodeY; const right = left + nodeWidth; const bottom = top + nodeHeight; minX = Math.min(minX, left); minY = Math.min(minY, top); maxX = Math.max(maxX, right); maxY = Math.max(maxY, bottom); }); // Calculate group center const groupWidth = maxX - minX; const groupHeight = maxY - minY; const groupCenterX = minX + groupWidth / 2; const groupCenterY = minY + groupHeight / 2; let snapDeltaX = 0; let snapDeltaY = 0; let showVerticalGuide = false; let showHorizontalGuide = false; // Disable snapping completely when Shift is held with multi-select // to prevent objects getting stuck at snap points if (!shiftPressed) { // Check horizontal center snapping for the group if (Math.abs(groupCenterX - centerX) < snapThreshold) { snapDeltaX = centerX - groupCenterX; showVerticalGuide = true; } // Check vertical center snapping for the group if (Math.abs(groupCenterY - centerY) < snapThreshold) { snapDeltaY = centerY - groupCenterY; showHorizontalGuide = true; } } // Apply snapping by moving all selected objects by the same delta if (showVerticalGuide || showHorizontalGuide) { selectedNodes.forEach(selectedNode => { if (snapDeltaX !== 0) { selectedNode.x(selectedNode.x() + snapDeltaX); } if (snapDeltaY !== 0) { selectedNode.y(selectedNode.y() + snapDeltaY); } }); setSnapGuides({ vertical: showVerticalGuide, horizontal: showHorizontalGuide }); } else { setSnapGuides({ vertical: false, horizontal: false }); } } } else { // Single object snapping (original logic) const nodeWidth = node.width(); const nodeHeight = node.height(); const nodeX = node.x(); const nodeY = node.y(); // Calculate object center position const objectCenterX = nodeX + (nodeWidth / 2); const objectCenterY = nodeY + (nodeHeight / 2); let snappedX = nodeX; let snappedY = nodeY; let showVerticalGuide = false; let showHorizontalGuide = false; // Determine movement direction if Shift is pressed let isHorizontalMovement = false; if (shiftPressed && dragStartPosRef.current) { const deltaX = Math.abs(nodeX - dragStartPosRef.current.x); const deltaY = Math.abs(nodeY - dragStartPosRef.current.y); isHorizontalMovement = deltaX > deltaY; } // Check horizontal center snapping // If Shift is pressed and moving vertically, don't snap horizontally if (Math.abs(objectCenterX - centerX) < snapThreshold && (!shiftPressed || isHorizontalMovement)) { snappedX = centerX - (nodeWidth / 2); showVerticalGuide = true; } // Check vertical center snapping // If Shift is pressed and moving horizontally, don't snap vertically if (Math.abs(objectCenterY - centerY) < snapThreshold && (!shiftPressed || !isHorizontalMovement)) { snappedY = centerY - (nodeHeight / 2); showHorizontalGuide = true; } // Apply snapping if (showVerticalGuide || showHorizontalGuide) { node.x(snappedX); node.y(snappedY); setSnapGuides({ vertical: showVerticalGuide, horizontal: showHorizontalGuide }); } else { setSnapGuides({ vertical: false, horizontal: false }); } } } }; // Handle drag end to update object position const handleDragEnd = (id: string) => (e: Konva.KonvaEventObject) => { const node = e.target; dragStartPosRef.current = null; // Reset drag start position dragStartPositions.current.clear(); // Clear multi-select start positions setSnapGuides({ vertical: false, horizontal: false }); // Clear snap guides // Check if multiple objects are selected const isMultiSelect = selectedIds.length > 1 && selectedIds.includes(id); if (isMultiSelect) { // For multi-select, debounce to ensure we only save once after all dragEnd events fire if (dragEndTimeoutRef.current) { clearTimeout(dragEndTimeoutRef.current); } dragEndTimeoutRef.current = setTimeout(() => { // Update ALL selected objects in a single state change const updatedObjects = objects.map(obj => { if (selectedIds.includes(obj.id)) { const objNode = shapeRefs.current.get(obj.id); if (objNode) { return { ...obj, x: objNode.x(), y: objNode.y() }; } } return obj; }); onObjectsChange(updatedObjects); dragEndTimeoutRef.current = null; }, 10); // Small delay to batch all dragEnd events } else { // Single object drag - update immediately const updatedObjects = objects.map(obj => obj.id === id ? { ...obj, x: node.x(), y: node.y() } : obj ); onObjectsChange(updatedObjects); } }; // Handle transform end to update object dimensions and rotation const handleTransformEnd = (id: string) => (e: Konva.KonvaEventObject) => { const node = e.target; const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Check if multiple objects are selected const isMultiSelect = selectedIds.length > 1; if (isMultiSelect) { // For multiselect, debounce to avoid multiple rapid updates // Clear any pending timeout if (transformEndTimeoutRef.current) { clearTimeout(transformEndTimeoutRef.current); } // Schedule update after a brief delay transformEndTimeoutRef.current = setTimeout(() => { // Update ALL selected objects proportionally const updatedObjects = objects.map(obj => { if (selectedIds.includes(obj.id)) { const objNode = shapeRefs.current.get(obj.id); if (objNode) { const nodeScaleX = objNode.scaleX(); const nodeScaleY = objNode.scaleY(); // Reset the node's scale objNode.scaleX(1); objNode.scaleY(1); const updated = { ...obj, x: objNode.x(), y: objNode.y(), width: Math.max(5, objNode.width() * nodeScaleX), height: Math.max(5, objNode.height() * nodeScaleY), rotation: objNode.rotation() }; // Handle text objects: scale fontSize if (obj.type === 'text') { const scaleFactor = Math.min(nodeScaleX, nodeScaleY); const newFontSize = Math.max(10, obj.fontSize * scaleFactor); return { ...updated, fontSize: newFontSize, isFixedSize: true }; } return updated; } } return obj; }); onObjectsChange(updatedObjects); transformEndTimeoutRef.current = null; }, 10); } else { // Single object transformation (original logic) // Reset scale to 1 and update width/height instead node.scaleX(1); node.scaleY(1); const updatedObjects = objects.map(obj => { if (obj.id === id) { const updated = { ...obj, x: node.x(), y: node.y(), width: Math.max(5, node.width() * scaleX), height: Math.max(5, node.height() * scaleY), rotation: node.rotation() }; // Handle text objects: scale fontSize and preserve offsets if (obj.type === 'text') { // Use the smaller scale factor to maintain readability const scaleFactor = Math.min(scaleX, scaleY); const newFontSize = Math.max(10, obj.fontSize * scaleFactor); return { ...updated, fontSize: newFontSize, isFixedSize: true }; } return updated; } return obj; }); onObjectsChange(updatedObjects); } }; // Handle text content changes const handleTextChange = (id: string, text: string, width: number, height: number) => { const updatedObjects = objects.map(obj => obj.id === id && obj.type === 'text' ? { ...obj, text, width, height } : obj ); onObjectsChange(updatedObjects); }; // Handle editing state changes const handleEditingChange = (id: string, isEditing: boolean, clickX?: number, clickY?: number) => { // If exiting edit mode (!isEditing), check if text is empty and delete if so if (!isEditing) { const textObj = objects.find(obj => obj.id === id && obj.type === 'text') as TextObject | undefined; if (textObj && textObj.text.trim() === '') { // Text is empty - delete the object const updatedObjects = objects.filter(obj => obj.id !== id); onObjectsChange(updatedObjects); return; } } const updatedObjects = objects.map(obj => obj.id === id && obj.type === 'text' ? { ...obj, isEditing, isFixedSize: isEditing ? obj.isFixedSize : true } : obj ); onObjectsChange(updatedObjects); // Calculate cursor position if click coordinates provided if (isEditing && clickX !== undefined && clickY !== undefined) { const textObj = objects.find(obj => obj.id === id && obj.type === 'text') as TextObject | undefined; if (textObj) { const cursorPos = calculateCursorPosition( textObj.text, clickX, textObj.fontSize, textObj.fontFamily, textObj.bold, textObj.italic, textObj.fontWeight ); cursorPositionRef.current = cursorPos; } } else if (isEditing) { // If no click position, set cursor to end cursorPositionRef.current = null; } }; // Focus textarea when editing starts useEffect(() => { if (editingText && textareaRef.current) { textareaRef.current.focus(); // Set cursor position based on click location if (cursorPositionRef.current !== null) { const pos = cursorPositionRef.current; textareaRef.current.setSelectionRange(pos, pos); cursorPositionRef.current = null; // Reset after use } else { // Default: cursor at end const len = textareaRef.current.value.length; textareaRef.current.setSelectionRange(len, len); } } }, [editingText?.id]); // Handle textarea text change const handleTextareaChange = (e: React.ChangeEvent) => { if (!editingText) return; const newText = e.target.value; try { // Calculate text dimensions const tempText = new Konva.Text({ text: newText || 'M', fontSize: editingText.fontSize, fontFamily: editingText.fontFamily, fontStyle: getFontStyle(editingText.bold, editingText.italic, editingText.fontWeight) }); // Always auto-grow/shrink box while editing (live resize) const newWidth = Math.max(100, tempText.width() + 20); const newHeight = Math.max(40, tempText.height() + 10); tempText.destroy(); handleTextChange(editingText.id, newText, newWidth, newHeight); } catch (error) { console.error('Error in textarea change:', error); handleTextChange(editingText.id, newText, editingText.width, editingText.height); } }; // Handle textarea blur const handleTextareaBlur = () => { if (editingText) { handleEditingChange(editingText.id, false); } }; // Get textarea position const getTextareaPosition = () => { if (!editingText || !stageRef.current) return { top: 0, left: 0 }; const stage = stageRef.current; const container = stage.container(); const containerRect = container.getBoundingClientRect(); return { top: containerRect.top + editingText.y, left: containerRect.left + editingText.x }; }; const textareaPos = editingText ? getTextareaPosition() : { top: 0, left: 0 }; // Calculate font size to fit text in fixed box (for live preview during editing) const calculateFitFontSize = (textObj: TextObject): number => { if (!textObj.isFixedSize || !textObj.text) return textObj.fontSize; try { const tempText = new Konva.Text({ text: textObj.text, fontSize: textObj.fontSize, fontFamily: textObj.fontFamily, fontStyle: getFontStyle(textObj.bold, textObj.italic, textObj.fontWeight), width: textObj.width, height: textObj.height }); let fontSize = textObj.fontSize; const maxWidth = textObj.width; const maxHeight = textObj.height; while (fontSize > 10) { tempText.fontSize(fontSize); const size = tempText.measureSize(textObj.text); if (size.width <= maxWidth && size.height <= maxHeight) { break; } fontSize -= 1; } tempText.destroy(); return fontSize; } catch (error) { return textObj.fontSize; } }; // Handle mouse down for selection box const handleMouseDown = (e: Konva.KonvaEventObject) => { // Only start selection box on empty canvas area if (e.target !== e.target.getStage()) return; // Don't start selection box in text creation mode if (textCreationMode) return; const stage = e.target.getStage(); const pos = stage?.getPointerPosition(); if (pos) { selectionStartRef.current = pos; setSelectionBox({ x: pos.x, y: pos.y, width: 0, height: 0 }); } }; // Handle mouse move for selection box const handleMouseMove = (e: Konva.KonvaEventObject) => { if (!selectionStartRef.current) return; const stage = e.target.getStage(); const pos = stage?.getPointerPosition(); if (pos) { const start = selectionStartRef.current; setSelectionBox({ x: Math.min(start.x, pos.x), y: Math.min(start.y, pos.y), width: Math.abs(pos.x - start.x), height: Math.abs(pos.y - start.y) }); } }; // Handle mouse up for selection box const handleMouseUp = (e: Konva.KonvaEventObject) => { if (!selectionStartRef.current) { return; } // Check if this was a drag or just a click const wasDragging = selectionBox && (selectionBox.width > 5 || selectionBox.height > 5); if (wasDragging) { // Find objects within selection box const selected: string[] = []; objects.forEach(obj => { // Check if object intersects with selection box const objBox = { x: obj.x, y: obj.y, width: obj.width, height: obj.height }; const boxIntersects = !( selectionBox.x > objBox.x + objBox.width || selectionBox.x + selectionBox.width < objBox.x || selectionBox.y > objBox.y + objBox.height || selectionBox.y + selectionBox.height < objBox.y ); if (boxIntersects) { selected.push(obj.id); } }); // Update selection if (selected.length > 0) { onSelect(selected); } else { // No objects in selection box, deselect all onSelect([]); } } else { // Was a click, not a drag - deselect all if (e.target === e.target.getStage()) { onSelect([]); } } // Reset selection box selectionStartRef.current = null; setSelectionBox(null); }; return (
setIsCanvasHovered(true)} onMouseLeave={() => setIsCanvasHovered(false)} style={{ width: dimensions.width, height: dimensions.height, backgroundImage: bgImageElement ? `url(${backgroundImage})` : 'none', backgroundColor: bgImageElement ? 'transparent' : '#ffffff', backgroundSize: 'cover', backgroundPosition: 'center', border: '1px solid #EBEBEB', borderRadius: isCanvasHovered ? '0px' : '10px', boxShadow: '0 4px 6px -2px rgba(5, 32, 81, 0.04), 0 12px 16px -4px rgba(5, 32, 81, 0.09)', overflow: 'hidden', transition: 'width 0.15s ease-in-out, height 0.15s ease-in-out, border-radius 0.15s ease-in-out' }} > { // Check if clicking on empty canvas area if (e.target === e.target.getStage()) { if (textCreationMode && onTextCreate) { // Get click position relative to the stage const stage = e.target.getStage(); const pointerPosition = stage?.getPointerPosition(); if (pointerPosition) { onTextCreate(pointerPosition.x, pointerPosition.y); } } // Note: deselection is now handled in handleMouseUp } }} style={{ cursor: textCreationMode ? 'text' : 'default' }} > {/* Background image for export */} {bgImageElement ? ( ) : ( )} {sortedObjects.map((obj) => { return ( ) => { const shiftKey = e?.evt?.shiftKey || false; handleSelect(obj.id, shiftKey); }} onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd(obj.id)} onTransformEnd={handleTransformEnd(obj.id)} onEditingChange={handleEditingChange} onMouseEnter={() => setHoveredId(obj.id)} onMouseLeave={() => setHoveredId(null)} shapeRef={(node: Konva.Node | null) => { if (node) { shapeRefs.current.set(obj.id, node); } else { shapeRefs.current.delete(obj.id); } }} /> ); })} {/* Selection box */} {selectionBox && ( )} {/* Snap guide lines */} {snapGuides.vertical && ( )} {snapGuides.horizontal && ( )} {/* Hover bounding box - simple outline without handles */} {hoveredId && !selectedIds.includes(hoveredId) && (() => { const hoveredNode = shapeRefs.current.get(hoveredId); if (hoveredNode) { const box = hoveredNode.getClientRect(); return ( ); } return null; })()} {/* Transformer for selected objects */} { // Limit minimum size if (newBox.width < 5 || newBox.height < 5) { return oldBox; } return newBox; }} /> {/* Textarea for text editing - rendered outside Konva */} {editingText && (