import { useEffect, useRef } from 'react'; import { Group, Rect, Text as KonvaText } from 'react-konva'; import { TextObject } from '../../types/canvas.types'; import Konva from 'konva'; // 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(); } // Calculate luminance of a color to determine if it's light or dark // Returns a value between 0 (black) and 255 (white) function getLuminance(hexColor: string): number { // Remove # if present const hex = hexColor.replace('#', ''); // Convert to RGB const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); // Calculate relative luminance using the formula for perceived brightness // https://www.w3.org/TR/WCAG20/#relativeluminancedef return 0.299 * r + 0.587 * g + 0.114 * b; } // Get contrasting text color (white or black) based on background color function getContrastTextColor(backgroundColor: string): string { const luminance = getLuminance(backgroundColor); // If luminance > 128, background is light, use black text // If luminance <= 128, background is dark, use white text return luminance > 128 ? '#000000' : '#FFFFFF'; } interface TextEditableProps { object: TextObject; isSelected: boolean; onSelect: (e?: Konva.KonvaEventObject) => void; onDragStart?: (e: Konva.KonvaEventObject) => void; onDragMove?: (e: Konva.KonvaEventObject) => void; onDragEnd?: (e: Konva.KonvaEventObject) => void; onTransformEnd?: (e: Konva.KonvaEventObject) => void; onEditingChange: (id: string, isEditing: boolean, clickX?: number, clickY?: number) => void; onMouseEnter?: () => void; onMouseLeave?: () => void; shapeRef?: ((node: Konva.Text | null) => void) | React.RefObject; } export default function TextEditable({ object, isSelected, onSelect, onDragStart, onDragMove, onDragEnd, onTransformEnd, onEditingChange, onMouseEnter, onMouseLeave, shapeRef }: TextEditableProps) { const textNodeRef = useRef(null); const groupRef = useRef(null); // Auto-enter edit mode when text is first created (empty text) useEffect(() => { if (object.text === '' && isSelected && !object.isEditing) { // Delay to ensure Konva node is fully rendered const timer = setTimeout(() => { onEditingChange(object.id, true); }, 100); return () => clearTimeout(timer); } }, [object.text, isSelected, object.isEditing, object.id, onEditingChange]); // Handle double-click to edit const handleDoubleClick = (e: Konva.KonvaEventObject) => { if (!object.isEditing) { const stage = e.target.getStage(); const pointerPos = stage?.getPointerPosition(); if (pointerPos) { // Get the layer to account for its offset const layer = e.target.getLayer(); const layerX = layer?.x() || 0; const layerY = layer?.y() || 0; // Get click position relative to the text object (accounting for layer offset) const clickX = pointerPos.x - layerX - object.x; const clickY = pointerPos.y - layerY - object.y; onEditingChange(object.id, true, clickX, clickY); } else { onEditingChange(object.id, true); } } }; // Always use the object's fontSize (no dynamic scaling) const displayFontSize = object.fontSize; // Background padding and border radius const bgPaddingTop = 8; const bgPaddingRight = 8; const bgPaddingBottom = 5; const bgPaddingLeft = 8; const bgRadius = 8; // If hasBackground is true, wrap in Group with background Rect if (object.hasBackground) { // Get background color (defaults to current fill if not set) const bgColor = object.backgroundColor || object.fill; // Calculate contrasting text color const textColor = getContrastTextColor(bgColor); return ( { groupRef.current = node; // Attach the Group to the transformer ref, not the inner text // Cast to any to handle the type mismatch between Group and Text if (typeof shapeRef === 'function') { (shapeRef as any)(node); } else if (shapeRef) { (shapeRef as any).current = node; } }} // Position the group so the VISUAL top-left (background rect) is at object.x, object.y x={object.x + bgPaddingLeft} y={object.y + bgPaddingTop} rotation={object.rotation} draggable={true} onClick={(e) => onSelect(e as Konva.KonvaEventObject)} onTap={(e) => onSelect(e as Konva.KonvaEventObject)} onDblClick={handleDoubleClick} onDblTap={handleDoubleClick} onDragStart={onDragStart} onDragMove={onDragMove} onDragEnd={onDragEnd} onTransformEnd={onTransformEnd} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} opacity={object.isEditing ? 0 : 1} listening={!object.isEditing} > {/* Background Rectangle */} {/* Text */} { // Store text node internally for measurements textNodeRef.current = node; }} x={0} y={0} width={object.width} height={object.height} text={object.text} fontSize={displayFontSize} fontFamily={object.fontFamily} fill={textColor} fontStyle={getFontStyle(object.bold, object.italic, object.fontWeight)} align={object.align || 'left'} verticalAlign="top" padding={0} lineHeight={1} draggable={false} /> ); } // No background - render text only return ( { // Call the callback ref if it's a function if (typeof shapeRef === 'function') { shapeRef(node); } else if (shapeRef) { // Set the RefObject if it's an object (shapeRef as React.MutableRefObject).current = node; } // Also store internally for our own use textNodeRef.current = node; }} x={object.x} y={object.y} width={object.width} height={object.height} text={object.text} fontSize={displayFontSize} fontFamily={object.fontFamily} fill={object.fill} fontStyle={getFontStyle(object.bold, object.italic, object.fontWeight)} align={object.align || 'left'} verticalAlign="top" rotation={object.rotation} padding={0} lineHeight={1} draggable={true} onClick={(e) => onSelect(e as Konva.KonvaEventObject)} onTap={(e) => onSelect(e as Konva.KonvaEventObject)} onDblClick={handleDoubleClick} onDblTap={handleDoubleClick} onDragStart={onDragStart} onDragMove={onDragMove} onDragEnd={onDragEnd} onTransformEnd={onTransformEnd} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} opacity={object.isEditing ? 0 : 1} listening={!object.isEditing} /> ); }