Skip to content

Instantly share code, notes, and snippets.

@Kadajett
Created July 31, 2024 16:29
Show Gist options
  • Select an option

  • Save Kadajett/f204dc84385feb90e0742024cd438135 to your computer and use it in GitHub Desktop.

Select an option

Save Kadajett/f204dc84385feb90e0742024cd438135 to your computer and use it in GitHub Desktop.
import Konva from 'konva';
import { AlertCircle, Crop, Image as ImageIcon, Move, Pen, Smile, Square } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { Image as KonvaImage, Layer, Line, Rect, Stage, Text, Transformer } from 'react-konva';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Slider } from '~/components/ui/slider';
type Tool = 'pan' | 'annotate' | 'redact' | 'draw' | 'crop' | 'emoji' | 'move';
interface Annotation {
id: string;
x: number;
y: number;
text: string;
}
interface Redaction {
id: string;
x: number;
y: number;
width: number;
height: number;
opacity: number;
color: string;
}
interface DrawingLine {
id: string;
tool: string;
points: number[];
}
interface Emoji {
id: string;
x: number;
y: number;
text: string;
}
interface ImageEditorProps {
initialImageSrc: string;
}
const ImageEditor: React.FC<ImageEditorProps> = ({ initialImageSrc }) => {
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [tool, setTool] = useState<Tool>('pan');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [redactions, setRedactions] = useState<Redaction[]>([]);
const [drawing, setDrawing] = useState<boolean>(false);
const [lines, setLines] = useState<DrawingLine[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const [stageScale, setStageScale] = useState<{ x: number; y: number }>({ x: 1, y: 1 });
const [stagePosition, setStagePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [currentRedaction, setCurrentRedaction] = useState<Redaction | null>(null);
const [editingAnnotation, setEditingAnnotation] = useState<string | null>(null);
const [emojis, setEmojis] = useState<Emoji[]>([]);
const [currentEmoji, setCurrentEmoji] = useState<string>('😊');
const [redactionOpacity, setRedactionOpacity] = useState<number>(100);
const [redactionColor, setRedactionColor] = useState<string>('#000000');
const [cropBox, setCropBox] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
useEffect(() => {
const img = new window.Image();
img.src = initialImageSrc;
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setError('Failed to load image');
setIsLoading(false);
};
}, [initialImageSrc]);
useEffect(() => {
if (selectedId && transformerRef.current && stageRef.current) {
const stage = stageRef.current;
const selectedNode = stage.findOne('#' + selectedId);
if (selectedNode) {
transformerRef.current.nodes([selectedNode]);
transformerRef.current.getLayer()?.batchDraw();
}
}
}, [selectedId]);
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (isLoading || error) return;
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
const scaledPos = {
x: (pos.x - stagePosition.x) / stageScale.x,
y: (pos.y - stagePosition.y) / stageScale.y
};
switch (tool) {
case 'annotate':
const newAnnotation: Annotation = { id: `annotation_${Date.now()}`, x: scaledPos.x, y: scaledPos.y, text: 'New annotation' };
setAnnotations(prev => [...prev, newAnnotation]);
break;
case 'redact':
const newRedaction: Redaction = { id: `redaction_${Date.now()}`, x: scaledPos.x, y: scaledPos.y, width: 0, height: 0, opacity: redactionOpacity / 100, color: redactionColor };
setCurrentRedaction(newRedaction);
break;
case 'draw':
setDrawing(true);
const newLine: DrawingLine = { id: `line_${Date.now()}`, tool, points: [scaledPos.x, scaledPos.y] };
setLines(prev => [...prev, newLine]);
break;
case 'emoji':
const newEmoji: Emoji = { id: `emoji_${Date.now()}`, x: scaledPos.x, y: scaledPos.y, text: currentEmoji };
setEmojis(prev => [...prev, newEmoji]);
break;
case 'crop':
setCropBox({ x: scaledPos.x, y: scaledPos.y, width: 0, height: 0 });
break;
case 'move':
const clickedOn = e.target;
if (clickedOn === e.target.getStage()) {
setSelectedId(null);
return;
}
setSelectedId(clickedOn.id());
break;
}
};
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
const point = stage?.getPointerPosition();
if (!point) return;
const scaledPoint = {
x: (point.x - stagePosition.x) / stageScale.x,
y: (point.y - stagePosition.y) / stageScale.y
};
if (drawing) {
setLines(prev => {
const lastLine = prev[prev.length - 1];
const newLastLine = {
...lastLine,
points: lastLine.points.concat([scaledPoint.x, scaledPoint.y])
};
return [...prev.slice(0, -1), newLastLine];
});
} else if (currentRedaction) {
setCurrentRedaction(prev => {
if (!prev) return null;
return {
...prev,
width: scaledPoint.x - prev.x,
height: scaledPoint.y - prev.y
};
});
} else if (cropBox) {
setCropBox(prev => {
if (!prev) return null;
return {
...prev,
width: scaledPoint.x - prev.x,
height: scaledPoint.y - prev.y
};
});
}
};
const handleMouseUp = () => {
setDrawing(false);
if (currentRedaction) {
setRedactions(prev => [...prev, currentRedaction]);
setCurrentRedaction(null);
}
if (cropBox) {
// Implement actual cropping logic here
if (image && cropBox.width > 0 && cropBox.height > 0) {
const canvas = document.createElement('canvas');
canvas.width = cropBox.width;
canvas.height = cropBox.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
image,
cropBox.x, cropBox.y, cropBox.width, cropBox.height,
0, 0, cropBox.width, cropBox.height
);
const croppedImageSrc = canvas.toDataURL();
const croppedImage = new window.Image();
croppedImage.src = croppedImageSrc;
croppedImage.onload = () => {
setImage(croppedImage);
};
}
}
setCropBox(null);
}
};
const handleToolChange = (newTool: Tool) => {
setTool(newTool);
setEditingAnnotation(null);
setSelectedId(null);
};
const handleTransform = (state: { scale: number; positionX: number; positionY: number }) => {
setStageScale({ x: state.scale, y: state.scale });
setStagePosition({ x: state.positionX, y: state.positionY });
};
const handleAnnotationClick = (id: string) => {
if (tool === 'annotate') {
setEditingAnnotation(id);
}
};
const handleAnnotationChange = (e: React.ChangeEvent<HTMLInputElement>, id: string) => {
setAnnotations(prev => prev.map(ann => ann.id === id ? { ...ann, text: e.target.value } : ann));
};
const handleEmojiChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentEmoji(e.target.value);
};
const handleRedactionOpacityChange = (newOpacity: number[]) => {
setRedactionOpacity(newOpacity[0]);
};
const handleRedactionColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setRedactionColor(e.target.value
);
};
if (isLoading) {
return <div>Loading image...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div className="flex flex-col items-center">
<div className="flex space-x-2 mb-4">
{(['pan', 'annotate', 'redact', 'draw', 'crop', 'emoji', 'move'] as Tool[]).map((t) => (
<Button
key={t}
onClick={() => handleToolChange(t)}
variant={tool === t ? 'default' : 'outline'}
>
{t === 'pan' && <ImageIcon />}
{t === 'annotate' && <AlertCircle />}
{t === 'redact' && <Square />}
{t === 'draw' && <Pen />}
{t === 'crop' && <Crop />}
{t === 'emoji' && <Smile />}
{t === 'move' && <Move />}
</Button>
))}
</div>
{tool === 'emoji' && (
<div className="w-64 mb-4">
<Input
type="text"
value={currentEmoji}
onChange={handleEmojiChange}
placeholder="Enter emoji"
/>
</div>
)}
{tool === 'redact' && (
<div className="w-64 mb-4 space-y-2">
<Slider
defaultValue={[redactionOpacity]}
min={10}
max={100}
step={1}
onValueChange={handleRedactionOpacityChange}
/>
<Input
type="color"
value={redactionColor}
onChange={handleRedactionColorChange}
/>
</div>
)}
<TransformWrapper
disabled={tool !== 'pan'}
onTransformed={({ state }) => handleTransform(state)}
>
<TransformComponent>
<Stage
width={800}
height={600}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
ref={stageRef}
>
<Layer>
{image && <KonvaImage image={image} />}
{redactions.map((rect) => (
<Rect
key={rect.id}
id={rect.id}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
fill={rect.color}
opacity={rect.opacity}
draggable={tool === 'move'}
/>
))}
{currentRedaction && (
<Rect
x={currentRedaction.x}
y={currentRedaction.y}
width={currentRedaction.width}
height={currentRedaction.height}
fill={currentRedaction.color}
opacity={currentRedaction.opacity}
/>
)}
{lines.map((line) => (
<Line
key={line.id}
id={line.id}
points={line.points}
stroke="red"
strokeWidth={5 / stageScale.x}
tension={0.5}
lineCap="round"
draggable={tool === 'move'}
/>
))}
{annotations.map((annotation) => (
<React.Fragment key={annotation.id}>
<Rect
id={`${annotation.id}_rect`}
x={annotation.x - 5}
y={annotation.y - 5}
width={10}
height={10}
fill="yellow"
onClick={() => handleAnnotationClick(annotation.id)}
draggable={tool === 'move'}
/>
<Text
id={`${annotation.id}_text`}
x={annotation.x + 10}
y={annotation.y + 10}
text={annotation.text}
fontSize={16 / stageScale.x}
fill="yellow"
onClick={() => handleAnnotationClick(annotation.id)}
draggable={tool === 'move'}
/>
</React.Fragment>
))}
{emojis.map((emoji) => (
<Text
key={emoji.id}
id={emoji.id}
x={emoji.x}
y={emoji.y}
text={emoji.text}
fontSize={24 / stageScale.x}
draggable={tool === 'move'}
/>
))}
{cropBox && (
<Rect
x={cropBox.x}
y={cropBox.y}
width={cropBox.width}
height={cropBox.height}
stroke="white"
strokeWidth={1 / stageScale.x}
dash={[4 / stageScale.x, 2 / stageScale.x]}
/>
)}
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
</Layer>
</Stage>
</TransformComponent>
</TransformWrapper>
{editingAnnotation !== null && (
<div className="mt-4">
<Input
type="text"
value={annotations.find(ann => ann.id === editingAnnotation)?.text || ''}
onChange={(e) => handleAnnotationChange(e, editingAnnotation)}
onBlur={() => setEditingAnnotation(null)}
autoFocus
/>
</div>
)}
</div>
);
};
export default ImageEditor;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment