diff --git a/frontend/src/components/ImageCanvas.css b/frontend/src/components/ImageCanvas.css new file mode 100644 index 0000000..3259a07 --- /dev/null +++ b/frontend/src/components/ImageCanvas.css @@ -0,0 +1,133 @@ +/* ImageCanvas fills the entire canvas wrapper */ +.canvas-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.canvas-container canvas { + display: block; +} + +.canvas-controls { + position: absolute; + bottom: 10px; + left: 10px; + right: 10px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 10; + pointer-events: none; +} + +.canvas-controls > * { + pointer-events: auto; +} + +.clear-selection-btn { + background-color: #ff4444; + color: white; + padding: 8px 16px; + border-radius: 4px; +} + +.clear-selection-btn:hover { + background-color: #cc0000; +} + +.selection-hint { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: #0088ff; + padding: 6px 12px; + border-radius: 4px; + font-size: 11px; + z-index: 10; + white-space: nowrap; + pointer-events: none; +} + +/* Clear selection button */ +.clear-selection-btn { + position: absolute; + top: 8px; + right: 8px; + background-color: #cc3333; + color: white; + padding: 6px 12px; + font-size: 11px; + border: none; + border-radius: 3px; + cursor: pointer; + z-index: 10; +} + +.clear-selection-btn:hover { + background-color: #dd4444; +} + +/* Zoom controls */ +.zoom-controls { + display: flex; + align-items: center; + gap: 4px; + background-color: rgba(0, 0, 0, 0.8); + padding: 6px 10px; + border-radius: 4px; +} + +.zoom-controls button { + width: 28px; + height: 28px; + padding: 0; + font-size: 18px; + font-weight: bold; + background-color: #444; + color: white; + border: 1px solid #666; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.zoom-controls button:hover { + background-color: #555; +} + +.zoom-level { + color: white; + font-size: 12px; + min-width: 45px; + text-align: center; +} + +/* Tool mode indicator */ +.tool-mode-indicator { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 120, 255, 0.9); + color: white; + padding: 10px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + z-index: 10; + white-space: nowrap; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx new file mode 100644 index 0000000..ff3a324 --- /dev/null +++ b/frontend/src/components/ImageCanvas.jsx @@ -0,0 +1,752 @@ +import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { fabric } from 'fabric'; +import './ImageCanvas.css'; + +const ImageCanvas = forwardRef(({ + imageUrl, + onSelectionChange, + selectionMode, + advancedToolMode, + onAdvancedToolClick, + zoom = 100, + onZoomChange, + externalSelection, + isProcessing +}, ref) => { + const canvasRef = useRef(null); + const fabricCanvasRef = useRef(null); + const [currentSelection, setCurrentSelection] = useState(null); + const [currentZoom, setCurrentZoom] = useState(1); + const currentSelectionRef = useRef(null); + const lassoPoints = useRef([]); + const onZoomChangeRef = useRef(onZoomChange); + const imageRef = useRef(null); + const baseScaleRef = useRef(1); + const isDrawingRef = useRef(false); + + // Keep ref updated + useEffect(() => { + onZoomChangeRef.current = onZoomChange; + }, [onZoomChange]); + + // Expose methods to parent + useImperativeHandle(ref, () => ({ + getCanvas: () => fabricCanvasRef.current, + clearSelection: () => clearSelection(), + })); + + // Update selection ref when state changes + useEffect(() => { + currentSelectionRef.current = currentSelection; + }, [currentSelection]); + + // Initialize canvas + useEffect(() => { + if (!canvasRef.current) return; + + const canvas = new fabric.Canvas(canvasRef.current, { + selection: false, + backgroundColor: 'transparent', + preserveObjectStacking: true, + }); + fabricCanvasRef.current = canvas; + + const handleResize = () => { + const container = canvasRef.current?.parentElement; + if (container) { + const width = container.clientWidth; + const height = container.clientHeight; + canvas.setWidth(width); + canvas.setHeight(height); + + // Re-center image if it exists + if (imageRef.current) { + centerImage(canvas, imageRef.current, zoom / 100); + } + canvas.renderAll(); + } + }; + + // Initial resize - use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + handleResize(); + }); + window.addEventListener('resize', handleResize); + + // Mouse wheel zoom + const handleWheel = (opt) => { + const e = opt.e; + e.preventDefault(); + e.stopPropagation(); + + const delta = e.deltaY; + let newZoom = canvas.getZoom(); + newZoom *= 0.999 ** delta; + + // Clamp zoom between 0.1x and 10x + if (newZoom > 10) newZoom = 10; + if (newZoom < 0.1) newZoom = 0.1; + + // Zoom to point under cursor + const pointer = canvas.getPointer(e, true); + canvas.zoomToPoint({ x: pointer.x, y: pointer.y }, newZoom); + + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + canvas.on('mouse:wheel', handleWheel); + + return () => { + window.removeEventListener('resize', handleResize); + canvas.off('mouse:wheel', handleWheel); + canvas.dispose(); + }; + }, []); // Empty dependency array - only run once on mount + + // Center and scale image + const centerImage = (canvas, img, zoomFactor) => { + if (!img) return; + + const padding = 40; + const availableWidth = canvas.width - padding; + const availableHeight = canvas.height - padding; + + // Calculate base scale to fit + const fitScale = Math.min( + availableWidth / img.width, + availableHeight / img.height + ); + + baseScaleRef.current = fitScale; + const scale = fitScale * zoomFactor; + + img.scale(scale); + img.set({ + left: (canvas.width - img.width * scale) / 2, + top: (canvas.height - img.height * scale) / 2, + }); + }; + + // Apply zoom changes + useEffect(() => { + const canvas = fabricCanvasRef.current; + if (!canvas || !imageRef.current) return; + + centerImage(canvas, imageRef.current, zoom / 100); + canvas.renderAll(); + }, [zoom]); + + // Load image when URL changes + useEffect(() => { + if (!fabricCanvasRef.current || !imageUrl) return; + + const canvas = fabricCanvasRef.current; + + // Ensure canvas has dimensions before loading image + if (canvas.width === 0 || canvas.height === 0) { + const container = canvasRef.current?.parentElement; + if (container) { + canvas.setWidth(container.clientWidth || 800); + canvas.setHeight(container.clientHeight || 600); + } + } + + // Add cache buster to force reload + const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`; + + fabric.Image.fromURL(cacheBustedUrl, (img) => { + if (!img) { + console.error('Failed to load image from URL:', cacheBustedUrl); + return; + } + + canvas.clear(); + + // Scale image to fit canvas with padding + const padding = 40; + const availableWidth = (canvas.width || 800) - padding; + const availableHeight = (canvas.height || 600) - padding; + const scale = Math.min( + availableWidth / img.width, + availableHeight / img.height + ); + + img.scale(scale); + img.set({ + left: ((canvas.width || 800) - img.width * scale) / 2, + top: ((canvas.height || 600) - img.height * scale) / 2, + selectable: false, + evented: false, + hoverCursor: 'default', + }); + + imageRef.current = img; + canvas.add(img); + canvas.sendToBack(img); + + centerImage(canvas, img, zoom / 100); + canvas.renderAll(); + }, { crossOrigin: 'anonymous' }); + }, [imageUrl]); + + // Handle tool/mode changes + useEffect(() => { + if (!fabricCanvasRef.current) return; + + const canvas = fabricCanvasRef.current; + + // Remove all event handlers + canvas.off('mouse:down'); + canvas.off('mouse:move'); + canvas.off('mouse:up'); + canvas.off('object:modified'); + canvas.off('object:moving'); + canvas.off('object:scaling'); + + // Set up handlers based on selection mode or advanced tool mode + if (advancedToolMode === 'smart-select') { + setupSmartSelectMode(canvas); + } else if (advancedToolMode === 'color-select') { + setupColorSelectMode(canvas); + } else if (selectionMode === 'rectangle') { + setupRectangleMode(canvas); + } else if (selectionMode === 'ellipse') { + setupEllipseMode(canvas); + } else if (selectionMode === 'lasso') { + setupLassoMode(canvas); + } else if (selectionMode === 'move') { + setupMoveMode(canvas); + } else if (selectionMode === 'pan') { + setupPanMode(canvas); + } + }, [selectionMode, advancedToolMode, onAdvancedToolClick, isProcessing]); + + const setupMoveMode = (canvas) => { + // In move mode, allow selecting and moving selection objects + const sel = currentSelectionRef.current; + if (sel) { + sel.set({ selectable: true, evented: true }); + canvas.setActiveObject(sel); + } + + canvas.on('object:modified', (e) => { + if (e.target && e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupPanMode = (canvas) => { + let isPanning = false; + let lastPosX, lastPosY; + + canvas.on('mouse:down', (e) => { + isPanning = true; + lastPosX = e.e.clientX; + lastPosY = e.e.clientY; + canvas.setCursor('grabbing'); + }); + + canvas.on('mouse:move', (e) => { + if (!isPanning) return; + + const deltaX = e.e.clientX - lastPosX; + const deltaY = e.e.clientY - lastPosY; + + canvas.relativePan({ x: deltaX, y: deltaY }); + + lastPosX = e.e.clientX; + lastPosY = e.e.clientY; + }); + + canvas.on('mouse:up', () => { + isPanning = false; + canvas.setCursor('grab'); + }); + + canvas.setCursor('grab'); + }; + + const setupSmartSelectMode = (canvas) => { + canvas.on('mouse:down', (e) => { + if (isProcessing) return; + + const pointer = canvas.getPointer(e.e); + const img = imageRef.current; + + if (!img) return; + + // Convert to image coordinates + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const x = Math.round((pointer.x - imgLeft) / imgScale); + const y = Math.round((pointer.y - imgTop) / imgScale); + + // Check if click is within image bounds + if (x >= 0 && x < img.width && y >= 0 && y < img.height) { + onAdvancedToolClick?.(x, y, null); + } + }); + + canvas.setCursor('crosshair'); + }; + + const setupColorSelectMode = (canvas) => { + canvas.on('mouse:down', (e) => { + if (isProcessing) return; + + const pointer = canvas.getPointer(e.e); + const img = imageRef.current; + + if (!img) return; + + // Convert to image coordinates + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const x = Math.round((pointer.x - imgLeft) / imgScale); + const y = Math.round((pointer.y - imgTop) / imgScale); + + // Check if click is within image bounds + if (x >= 0 && x < img.width && y >= 0 && y < img.height) { + // Get pixel color from canvas + const ctx = canvas.getContext('2d'); + if (ctx) { + // Calculate actual canvas position accounting for viewport transform + const vpt = canvas.viewportTransform; + const canvasX = pointer.x * vpt[0] + vpt[4]; + const canvasY = pointer.y * vpt[3] + vpt[5]; + + const pixelData = ctx.getImageData(canvasX, canvasY, 1, 1).data; + const color = { + r: pixelData[0], + g: pixelData[1], + b: pixelData[2] + }; + onAdvancedToolClick?.(x, y, color); + } + } + }); + + canvas.setCursor('crosshair'); + }; + + const setupRectangleMode = (canvas) => { + let rect = null; + let isDown = false; + let startX, startY; + + canvas.on('mouse:down', (e) => { + // Check if clicking on existing selection + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + // Allow moving/transforming + return; + } + + // Clear previous selection + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDown = true; + isDrawingRef.current = true; + const pointer = canvas.getPointer(e.e); + startX = pointer.x; + startY = pointer.y; + + rect = new fabric.Rect({ + left: startX, + top: startY, + width: 0, + height: 0, + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(rect); + }); + + canvas.on('mouse:move', (e) => { + if (!isDown || !rect) return; + + const pointer = canvas.getPointer(e.e); + const width = pointer.x - startX; + const height = pointer.y - startY; + + rect.set({ + width: Math.abs(width), + height: Math.abs(height), + left: width < 0 ? pointer.x : startX, + top: height < 0 ? pointer.y : startY, + }); + + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDown && rect && rect.width > 5 && rect.height > 5) { + isDown = false; + isDrawingRef.current = false; + setCurrentSelection(rect); + canvas.setActiveObject(rect); + updateSelection(rect, 'rectangle'); + } else if (isDown && rect) { + // Selection too small, remove it + canvas.remove(rect); + isDown = false; + isDrawingRef.current = false; + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupEllipseMode = (canvas) => { + let ellipse = null; + let isDown = false; + let startX, startY; + + canvas.on('mouse:down', (e) => { + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + return; + } + + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDown = true; + const pointer = canvas.getPointer(e.e); + startX = pointer.x; + startY = pointer.y; + + ellipse = new fabric.Ellipse({ + left: startX, + top: startY, + rx: 0, + ry: 0, + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(ellipse); + }); + + canvas.on('mouse:move', (e) => { + if (!isDown || !ellipse) return; + + const pointer = canvas.getPointer(e.e); + const rx = Math.abs(pointer.x - startX) / 2; + const ry = Math.abs(pointer.y - startY) / 2; + + ellipse.set({ + rx: rx, + ry: ry, + left: Math.min(startX, pointer.x), + top: Math.min(startY, pointer.y), + }); + + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) { + isDown = false; + setCurrentSelection(ellipse); + canvas.setActiveObject(ellipse); + updateSelection(ellipse, 'ellipse'); + } else if (isDown && ellipse) { + canvas.remove(ellipse); + isDown = false; + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupLassoMode = (canvas) => { + let points = []; + let drawingLine = null; + let polygon = null; + + canvas.on('mouse:down', (e) => { + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + return; + } + + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDrawingRef.current = true; + const pointer = canvas.getPointer(e.e); + points = [{ x: pointer.x, y: pointer.y }]; + + drawingLine = new fabric.Polyline(points, { + fill: 'transparent', + stroke: '#0088ff', + strokeWidth: 2, + selectable: false, + evented: false, + }); + + canvas.add(drawingLine); + }); + + canvas.on('mouse:move', (e) => { + if (!isDrawingRef.current) return; + + const pointer = canvas.getPointer(e.e); + points.push({ x: pointer.x, y: pointer.y }); + + canvas.remove(drawingLine); + drawingLine = new fabric.Polyline([...points], { + fill: 'transparent', + stroke: '#0088ff', + strokeWidth: 2, + selectable: false, + evented: false, + }); + canvas.add(drawingLine); + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDrawingRef.current && points.length > 5) { + isDrawingRef.current = false; + lassoPoints.current = [...points]; + + canvas.remove(drawingLine); + + polygon = new fabric.Polygon(points, { + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(polygon); + canvas.setActiveObject(polygon); + setCurrentSelection(polygon); + updateSelection(polygon, 'lasso'); + } else if (isDrawingRef.current) { + isDrawingRef.current = false; + canvas.remove(drawingLine); + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const updateSelection = (selection, type) => { + if (!selection || !imageRef.current) return; + + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + let bbox, selectionData = null; + + if (type === 'rectangle') { + bbox = { + x: Math.round((selection.left - imgLeft) / imgScale), + y: Math.round((selection.top - imgTop) / imgScale), + width: Math.round(selection.width / imgScale), + height: Math.round(selection.height / imgScale), + }; + } else if (type === 'ellipse') { + bbox = { + x: Math.round((selection.left - imgLeft) / imgScale), + y: Math.round((selection.top - imgTop) / imgScale), + width: Math.round((selection.rx * 2) / imgScale), + height: Math.round((selection.ry * 2) / imgScale), + }; + } else if (type === 'lasso') { + const bounds = selection.getBoundingRect(); + bbox = { + x: Math.round((bounds.left - imgLeft) / imgScale), + y: Math.round((bounds.top - imgTop) / imgScale), + width: Math.round(bounds.width / imgScale), + height: Math.round(bounds.height / imgScale), + }; + + const relativePoints = lassoPoints.current.map(p => [ + Math.round((p.x - imgLeft) / imgScale) - bbox.x, + Math.round((p.y - imgTop) / imgScale) - bbox.y, + ]); + + selectionData = { points: relativePoints }; + } + + onSelectionChange?.({ + type, + bbox, + selectionData, + }); + }; + + const updateTransformedSelection = (selection) => { + if (!selection || !imageRef.current) return; + + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const bounds = selection.getBoundingRect(true); + + const bbox = { + x: Math.round((bounds.left - imgLeft) / imgScale), + y: Math.round((bounds.top - imgTop) / imgScale), + width: Math.round(bounds.width / imgScale), + height: Math.round(bounds.height / imgScale), + }; + + let selectionData = null; + const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle'); + + if (type === 'lasso' && lassoPoints.current.length > 0) { + const matrix = selection.calcTransformMatrix(); + const transformedPoints = lassoPoints.current.map(p => { + const transformed = fabric.util.transformPoint( + new fabric.Point(p.x, p.y), + matrix + ); + return [ + Math.round((transformed.x - imgLeft) / imgScale) - bbox.x, + Math.round((transformed.y - imgTop) / imgScale) - bbox.y, + ]; + }); + selectionData = { points: transformedPoints }; + } + + onSelectionChange?.({ + type, + bbox, + selectionData, + }); + }; + + const clearSelection = () => { + const canvas = fabricCanvasRef.current; + const sel = currentSelectionRef.current; + if (sel && canvas) { + canvas.remove(sel); + setCurrentSelection(null); + onSelectionChange?.(null); + } + }; + + const handleZoomIn = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + let newZoom = canvas.getZoom() * 1.2; + if (newZoom > 10) newZoom = 10; + canvas.setZoom(newZoom); + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + const handleZoomOut = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + let newZoom = canvas.getZoom() / 1.2; + if (newZoom < 0.1) newZoom = 0.1; + canvas.setZoom(newZoom); + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + const handleZoomReset = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + canvas.setZoom(1); + canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + setCurrentZoom(1); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(1); + } + }; + + return ( +
+ +
+
+ + {Math.round(currentZoom * 100)}% + + +
+ {currentSelection && ( + + )} +
+ {(advancedToolMode === 'smart-select' || advancedToolMode === 'color-select') && ( +
+ {advancedToolMode === 'smart-select' ? 'Click on an object to select it' : 'Click on a color to select similar pixels'} +
+ )} +
+ ); +}); + +ImageCanvas.displayName = 'ImageCanvas'; + +export default ImageCanvas;