cffy.in/public/snapmark/components/EditorCanvas.tsx

419 lines
12 KiB
TypeScript

import React, { useRef, useEffect } from 'react';
import { Stage, Layer, Image as KonvaImage, Rect, Arrow, Text, Transformer } from 'react-konva';
import useImage from 'use-image';
import Konva from 'konva';
import { Shape, ToolType } from '../types';
// Helper component to load the image
const URLImage = ({ src, onLoaded }: { src: string; onLoaded: (w: number, h: number) => void }) => {
const [image] = useImage(src);
useEffect(() => {
if (image) {
onLoaded(image.width, image.height);
}
}, [image, onLoaded]);
return <KonvaImage image={image} />;
};
interface EditorCanvasProps {
imageSrc: string | null;
shapes: Shape[];
setShapes: React.Dispatch<React.SetStateAction<Shape[]>>;
selectedId: string | null;
setSelectedId: (id: string | null) => void;
tool: ToolType;
setTool: (tool: ToolType) => void;
stageRef: React.RefObject<Konva.Stage>;
onImageDimensionsChange: (width: number, height: number) => void;
canvasWidth?: number;
canvasHeight?: number;
}
const EditorCanvas: React.FC<EditorCanvasProps> = ({
imageSrc,
shapes,
setShapes,
selectedId,
setSelectedId,
tool,
setTool,
stageRef,
onImageDimensionsChange,
canvasWidth,
canvasHeight,
}) => {
const isDrawing = useRef(false);
const transformerRef = useRef<Konva.Transformer>(null);
// Attach transformer to selected shape
useEffect(() => {
if (selectedId && transformerRef.current && stageRef.current) {
const node = stageRef.current.findOne('#' + selectedId);
if (node) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
}
}, [selectedId, shapes]);
const handleTextDblClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const textNode = e.target as Konva.Text;
const stage = textNode.getStage();
if (!stage) return;
// Hide node while editing
textNode.hide();
// Determine position
const textPosition = textNode.absolutePosition();
const stageBox = stage.container().getBoundingClientRect();
const areaPosition = {
x: stageBox.left + textPosition.x,
y: stageBox.top + textPosition.y,
};
// Create textarea
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = textNode.text();
textarea.style.position = 'absolute';
textarea.style.top = `${areaPosition.y + window.scrollY}px`;
textarea.style.left = `${areaPosition.x + window.scrollX}px`;
textarea.style.width = `${textNode.width() - textNode.padding() * 2}px`;
textarea.style.height = `${textNode.height() - textNode.padding() * 2 + 5}px`;
textarea.style.fontSize = `${textNode.fontSize()}px`;
textarea.style.border = 'none';
textarea.style.padding = '0px';
textarea.style.margin = '0px';
textarea.style.overflow = 'hidden';
textarea.style.background = 'none';
textarea.style.outline = 'none';
textarea.style.resize = 'none';
textarea.style.lineHeight = textNode.lineHeight().toString();
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.fontWeight = textNode.fontStyle();
textarea.style.transformOrigin = 'left top';
textarea.style.textAlign = textNode.align();
textarea.style.color = textNode.fill();
textarea.style.zIndex = '1000';
const rotation = textNode.rotation();
let transform = '';
if (rotation) {
transform += `rotateZ(${rotation}deg)`;
}
textarea.style.transform = transform;
// Auto-height
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 3 + 'px';
textarea.focus();
const removeTextarea = () => {
if (textarea.parentNode) {
textarea.parentNode.removeChild(textarea);
}
textNode.show();
};
const handleBlur = () => {
const newText = textarea.value;
const id = textNode.id();
setShapes(prev => prev.map(s => s.id === id ? { ...s, text: newText } : s));
removeTextarea();
};
const handleKeydown = (e: KeyboardEvent) => {
// Enter without shift updates text
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textarea.blur();
}
// Escape cancels
if (e.key === 'Escape') {
removeTextarea();
}
};
textarea.addEventListener('blur', handleBlur);
textarea.addEventListener('keydown', handleKeydown);
};
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (tool === 'select') {
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
setSelectedId(null);
transformerRef.current?.nodes([]);
}
return;
}
isDrawing.current = true;
const stage = e.target.getStage();
const pos = stage?.getRelativePointerPosition();
if (!pos) return;
// Create new shape based on tool
const baseShape = {
id: Math.random().toString(36).substr(2, 9),
x: pos.x,
y: pos.y,
stroke: '#be185d', // Default to Pink (cffyin vibes)
strokeWidth: 4,
};
let newShape: Shape;
if (tool === 'rect') {
newShape = {
...baseShape,
type: 'rect',
width: 0,
height: 0,
fill: 'transparent',
cornerRadius: 15,
};
} else if (tool === 'arrow') {
newShape = {
...baseShape,
type: 'arrow',
points: [0, 0, 0, 0], // Relative points
};
} else if (tool === 'text') {
newShape = {
...baseShape,
type: 'text',
text: 'Double Click to Edit',
fontSize: 32,
fill: '#be185d',
};
// Text doesn't drag-create, it just places
isDrawing.current = false;
setShapes([...shapes, newShape]);
setSelectedId(newShape.id);
setTool('select');
return;
} else {
return;
}
setShapes([...shapes, newShape]);
setSelectedId(newShape.id);
};
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing.current || tool === 'select' || tool === 'text') return;
const stage = e.target.getStage();
const pos = stage?.getRelativePointerPosition();
if (!pos) return;
// Update the last shape
const lastShape = shapes[shapes.length - 1];
// Safety check to ensure we are updating the shape we just created
if(lastShape.id !== selectedId) return;
const newShapes = shapes.slice(0, shapes.length - 1);
if (lastShape.type === 'rect') {
newShapes.push({
...lastShape,
width: pos.x - lastShape.x,
height: pos.y - lastShape.y,
});
} else if (lastShape.type === 'arrow') {
const relX = pos.x - lastShape.x;
const relY = pos.y - lastShape.y;
newShapes.push({
...lastShape,
points: [0, 0, relX, relY],
});
}
setShapes(newShapes);
};
const handleMouseUp = () => {
if(isDrawing.current) {
isDrawing.current = false;
// Switch back to select tool after drawing usually improves UX for annotation tools
setTool('select');
}
};
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>, id: string) => {
// Update shape position in state
const shapeIndex = shapes.findIndex(s => s.id === id);
if (shapeIndex === -1) return;
const updatedShapes = [...shapes];
updatedShapes[shapeIndex] = {
...updatedShapes[shapeIndex],
x: e.target.x(),
y: e.target.y(),
};
setShapes(updatedShapes);
};
const handleTransformEnd = (e: Konva.KonvaEventObject<Event>) => {
// transform end logic to save scale/rotation back to state
const node = e.target;
const id = node.id();
const shapeIndex = shapes.findIndex(s => s.id === id);
if (shapeIndex === -1) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Reset scale to 1 and adjust width/height/points to prevent compounding scales
node.scaleX(1);
node.scaleY(1);
const updatedShapes = [...shapes];
const shape = updatedShapes[shapeIndex];
if (shape.type === 'rect') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, node.width() * scaleX), // Prevent negative width
height: Math.max(5, node.height() * scaleY),
};
} else if (shape.type === 'text') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: shape.fontSize * scaleX, // Approximation for text scaling
};
} else if (shape.type === 'arrow') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
};
// Re-apply the scale to the node so visual doesn't jump
node.scaleX(scaleX);
node.scaleY(scaleY);
}
setShapes(updatedShapes);
};
return (
<Stage
ref={stageRef}
width={canvasWidth || window.innerWidth}
height={canvasHeight || window.innerHeight}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className="bg-white"
style={{ cursor: tool === 'select' ? 'default' : 'crosshair' }}
>
<Layer>
{imageSrc && (
<URLImage
src={imageSrc}
onLoaded={onImageDimensionsChange}
/>
)}
{shapes.map((shape) => {
const commonProps = {
key: shape.id,
id: shape.id,
x: shape.x,
y: shape.y,
rotation: shape.rotation || 0,
draggable: tool === 'select',
onClick: () => {
if(tool === 'select') setSelectedId(shape.id);
},
onTap: () => {
if(tool === 'select') setSelectedId(shape.id);
},
onDragEnd: (e: any) => handleDragEnd(e, shape.id),
onTransformEnd: handleTransformEnd,
};
if (shape.type === 'rect') {
return (
<Rect
{...commonProps}
width={shape.width}
height={shape.height}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
fill={shape.fill}
cornerRadius={shape.cornerRadius}
/>
);
} else if (shape.type === 'arrow') {
return (
<Arrow
{...commonProps}
points={shape.points}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
fill={shape.stroke} // Arrow head fill
pointerLength={10}
pointerWidth={10}
/>
);
} else if (shape.type === 'text') {
return (
<Text
{...commonProps}
text={shape.text}
fontSize={shape.fontSize}
fill={shape.stroke}
stroke={'#FFFF00'} // Radiant yellow outline
strokeWidth={2} // Outline thickness
strokeJoin="round" // For better outline appearance
fontFamily="Comic Sans MS, Comic Sans, cursive"
fontStyle="bold"
onDblClick={handleTextDblClick}
onDblTap={handleTextDblClick}
/>
);
}
return null;
})}
{selectedId && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
rotateEnabled={true}
borderStroke="#d97706" // amber-600
anchorStroke="#451a03" // amber-950
anchorFill="#fcd34d" // amber-300
anchorSize={10}
anchorCornerRadius={2}
/>
)}
</Layer>
</Stage>
);
};
export default EditorCanvas;