419 lines
12 KiB
TypeScript
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; |