Created
July 31, 2024 16:29
-
-
Save Kadajett/f204dc84385feb90e0742024cd438135 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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