diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx index 4d87979..1dbda67 100644 --- a/frontend/src/components/ImageCanvas.jsx +++ b/frontend/src/components/ImageCanvas.jsx @@ -1,26 +1,47 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; import { fabric } from 'fabric'; import './ImageCanvas.css'; -const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { +const ImageCanvas = forwardRef(({ + imageUrl, + onSelectionChange, + selectionMode, + activeTool, + zoom = 100, + onSmartSelect, + isProcessing +}, ref) => { const canvasRef = useRef(null); const fabricCanvasRef = useRef(null); const [currentSelection, setCurrentSelection] = useState(null); - const [isDrawing, setIsDrawing] = useState(false); - const [isTransformMode, setIsTransformMode] = useState(false); + const currentSelectionRef = useRef(null); const lassoPoints = useRef([]); + const isDrawingRef = useRef(false); + const imageRef = useRef(null); + const baseScaleRef = useRef(1); + // 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; - // Initialize Fabric.js canvas const canvas = new fabric.Canvas(canvasRef.current, { selection: false, - backgroundColor: '#2a2a2a', + backgroundColor: 'transparent', + preserveObjectStacking: true, }); fabricCanvasRef.current = canvas; - // Handle window resize const handleResize = () => { const container = canvasRef.current?.parentElement; if (container) { @@ -29,19 +50,9 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { canvas.setWidth(width); canvas.setHeight(height); - // Re-center and rescale the image if it exists - const bgImage = canvas.backgroundImage; - if (bgImage) { - // Allow scaling up to fill the canvas - const scale = Math.min( - (width - 40) / bgImage.width, - (height - 40) / bgImage.height - ); - bgImage.scale(scale); - bgImage.set({ - left: (width - bgImage.width * scale) / 2, - top: (height - bgImage.height * scale) / 2, - }); + // Re-center image if it exists + if (imageRef.current) { + centerImage(canvas, imageRef.current, zoom / 100); } canvas.renderAll(); } @@ -56,97 +67,211 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }; }, []); + // 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; - - // Add cache buster to force reload - const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`; + const cacheBustedUrl = imageUrl.includes('?') ? `${imageUrl}&_t=${Date.now()}` : `${imageUrl}?t=${Date.now()}`; fabric.Image.fromURL(cacheBustedUrl, (img) => { - canvas.clear(); + // Remove old image + if (imageRef.current) { + canvas.remove(imageRef.current); + } - // Scale image to fit canvas with padding - const padding = 40; - const availableWidth = canvas.width - padding; - const availableHeight = canvas.height - padding; - const scale = Math.min( - availableWidth / img.width, - availableHeight / img.height - ); + // Clear selection + if (currentSelectionRef.current) { + canvas.remove(currentSelectionRef.current); + setCurrentSelection(null); + onSelectionChange(null); + } - img.scale(scale); img.set({ - left: (canvas.width - img.width * scale) / 2, - top: (canvas.height - img.height * scale) / 2, selectable: false, evented: false, + hoverCursor: 'default', }); + imageRef.current = img; canvas.add(img); canvas.sendToBack(img); - canvas.renderAll(); - // Store image reference - canvas.backgroundImage = img; + centerImage(canvas, img, zoom / 100); + canvas.renderAll(); }, { crossOrigin: 'anonymous' }); }, [imageUrl]); - // Handle selection mode changes + // Handle tool/mode changes useEffect(() => { if (!fabricCanvasRef.current) return; const canvas = fabricCanvasRef.current; - // Clear previous selection when changing modes - if (currentSelection) { - canvas.remove(currentSelection); - setCurrentSelection(null); - onSelectionChange(null); - } - - // Reset transform mode - setIsTransformMode(false); - - // Set up event handlers based on mode + // 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 if (selectionMode === 'rectangle') { setupRectangleMode(canvas); } else if (selectionMode === 'ellipse') { setupEllipseMode(canvas); } else if (selectionMode === 'lasso') { setupLassoMode(canvas); + } else if (selectionMode === 'smart') { + setupSmartSelectMode(canvas); + } else if (selectionMode === 'color') { + setupColorSelectMode(canvas); + } else if (activeTool === 'move') { + setupMoveMode(canvas); + } else if (activeTool === 'pan') { + setupPanMode(canvas); } - }, [selectionMode]); + }, [selectionMode, activeTool, onSmartSelect]); - const setupRectangleMode = (canvas) => { - let rect, isDown, startX, startY; + 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) => { - // If clicking on existing selection, enable transform mode - if (e.target && e.target === currentSelection) { - setIsTransformMode(true); + 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) { + onSmartSelect?.(x, y); + } + }); + + canvas.setCursor('crosshair'); + }; + + const setupColorSelectMode = (canvas) => { + canvas.on('mouse:down', (e) => { + if (isProcessing) return; + + // TODO: Get pixel color at click position + const pointer = canvas.getPointer(e.e); + console.log('Color select at:', pointer); + }); + + 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; } - // If in transform mode and clicking elsewhere, exit transform mode - if (isTransformMode) { - setIsTransformMode(false); - } - - // Clear previous selection if exists - if (currentSelection) { - canvas.remove(currentSelection); + // 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; @@ -156,26 +281,24 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { top: startY, width: 0, height: 0, - fill: 'rgba(255, 255, 255, 0.3)', - stroke: '#00ff00', + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', strokeWidth: 2, + strokeDashArray: [5, 5], selectable: true, hasControls: true, hasBorders: true, - lockRotation: false, - cornerColor: '#00ff00', - cornerSize: 10, + cornerColor: '#0088ff', + cornerSize: 8, transparentCorners: false, - borderColor: '#00ff00', - borderScaleFactor: 2, + borderColor: '#0088ff', }); canvas.add(rect); - setCurrentSelection(rect); }); canvas.on('mouse:move', (e) => { - if (!isDown || isTransformMode) return; + if (!isDown || !rect) return; const pointer = canvas.getPointer(e.e); const width = pointer.x - startX; @@ -192,39 +315,40 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:up', () => { - if (isDown && !isTransformMode) { + 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; } }); - // Update selection when object is modified (moved, scaled, rotated) canvas.on('object:modified', (e) => { - if (e.target && e.target === currentSelection) { - updateTransformedSelection(e.target, 'rectangle'); + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); } }); }; const setupEllipseMode = (canvas) => { - let ellipse, isDown, startX, startY; + let ellipse = null; + let isDown = false; + let startX, startY; canvas.on('mouse:down', (e) => { - // If clicking on existing selection, enable transform mode - if (e.target && e.target === currentSelection) { - setIsTransformMode(true); + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { return; } - // If in transform mode and clicking elsewhere, exit transform mode - if (isTransformMode) { - setIsTransformMode(false); - } - - // Clear previous selection if exists - if (currentSelection) { - canvas.remove(currentSelection); + if (sel) { + canvas.remove(sel); setCurrentSelection(null); } @@ -238,26 +362,24 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { top: startY, rx: 0, ry: 0, - fill: 'rgba(255, 255, 255, 0.3)', - stroke: '#00ff00', + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', strokeWidth: 2, + strokeDashArray: [5, 5], selectable: true, hasControls: true, hasBorders: true, - lockRotation: false, - cornerColor: '#00ff00', - cornerSize: 10, + cornerColor: '#0088ff', + cornerSize: 8, transparentCorners: false, - borderColor: '#00ff00', - borderScaleFactor: 2, + borderColor: '#0088ff', }); canvas.add(ellipse); - setCurrentSelection(ellipse); }); canvas.on('mouse:move', (e) => { - if (!isDown || isTransformMode) return; + if (!isDown || !ellipse) return; const pointer = canvas.getPointer(e.e); const rx = Math.abs(pointer.x - startX) / 2; @@ -266,58 +388,55 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { ellipse.set({ rx: rx, ry: ry, - left: startX < pointer.x ? startX : pointer.x, - top: startY < pointer.y ? startY : pointer.y, + left: Math.min(startX, pointer.x), + top: Math.min(startY, pointer.y), }); canvas.renderAll(); }); canvas.on('mouse:up', () => { - if (isDown && !isTransformMode) { + 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; } }); - // Update selection when object is modified (moved, scaled, rotated) canvas.on('object:modified', (e) => { - if (e.target && e.target === currentSelection) { - updateTransformedSelection(e.target, 'ellipse'); + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); } }); }; const setupLassoMode = (canvas) => { - let polygon, points = [], drawingLine; + let points = []; + let drawingLine = null; + let polygon = null; canvas.on('mouse:down', (e) => { - // If clicking on existing selection, enable transform mode - if (e.target && e.target === currentSelection) { - setIsTransformMode(true); + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { return; } - // If in transform mode and clicking elsewhere, exit transform mode - if (isTransformMode) { - setIsTransformMode(false); - } - - // Clear previous selection if exists - if (currentSelection) { - canvas.remove(currentSelection); + if (sel) { + canvas.remove(sel); setCurrentSelection(null); } - setIsDrawing(true); + isDrawingRef.current = true; const pointer = canvas.getPointer(e.e); points = [{ x: pointer.x, y: pointer.y }]; - // Create a temporary line for visual feedback while drawing drawingLine = new fabric.Polyline(points, { fill: 'transparent', - stroke: '#00ff00', + stroke: '#0088ff', strokeWidth: 2, selectable: false, evented: false, @@ -327,16 +446,15 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:move', (e) => { - if (!isDrawing || isTransformMode) return; + if (!isDrawingRef.current) return; const pointer = canvas.getPointer(e.e); points.push({ x: pointer.x, y: pointer.y }); - // Remove old line and create new one with updated points canvas.remove(drawingLine); drawingLine = new fabric.Polyline([...points], { fill: 'transparent', - stroke: '#00ff00', + stroke: '#0088ff', strokeWidth: 2, selectable: false, evented: false, @@ -346,59 +464,50 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:up', () => { - if (isDrawing && !isTransformMode && points.length > 2) { - setIsDrawing(false); + if (isDrawingRef.current && points.length > 5) { + isDrawingRef.current = false; lassoPoints.current = [...points]; - // Remove drawing line canvas.remove(drawingLine); - // Create final polygon with fill polygon = new fabric.Polygon(points, { - fill: 'rgba(255, 255, 255, 0.3)', - stroke: '#00ff00', + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', strokeWidth: 2, + strokeDashArray: [5, 5], selectable: true, hasControls: true, hasBorders: true, - lockRotation: false, - cornerColor: '#00ff00', - cornerSize: 10, + cornerColor: '#0088ff', + cornerSize: 8, transparentCorners: false, - borderColor: '#00ff00', - borderScaleFactor: 2, + borderColor: '#0088ff', }); canvas.add(polygon); canvas.setActiveObject(polygon); setCurrentSelection(polygon); updateSelection(polygon, 'lasso'); - } else if (isDrawing) { - setIsDrawing(false); + } else if (isDrawingRef.current) { + isDrawingRef.current = false; canvas.remove(drawingLine); } }); - // Update selection when object is modified (moved, scaled, rotated) canvas.on('object:modified', (e) => { - if (e.target && e.target === currentSelection) { - updateTransformedSelection(e.target, 'lasso'); + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); } }); }; const updateSelection = (selection, type) => { - if (!selection || !fabricCanvasRef.current) return; + if (!selection || !imageRef.current) return; - const canvas = fabricCanvasRef.current; - const bgImage = canvas.backgroundImage; - - if (!bgImage) return; - - // Calculate bounding box in original image coordinates - const imgScale = bgImage.scaleX; - const imgLeft = bgImage.left; - const imgTop = bgImage.top; + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; let bbox, selectionData = null; @@ -425,39 +534,32 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { height: Math.round(bounds.height / imgScale), }; - // Convert lasso points to relative coordinates within bbox const relativePoints = lassoPoints.current.map(p => [ - Math.round((p.x - bounds.left) / imgScale), - Math.round((p.y - bounds.top) / imgScale), + Math.round((p.x - imgLeft) / imgScale) - bbox.x, + Math.round((p.y - imgTop) / imgScale) - bbox.y, ]); selectionData = { points: relativePoints }; } - onSelectionChange({ + onSelectionChange?.({ type, bbox, selectionData, }); }; - // Update selection after transformation (move, scale, rotate) - const updateTransformedSelection = (selection, type) => { - if (!selection || !fabricCanvasRef.current) return; + const updateTransformedSelection = (selection) => { + if (!selection || !imageRef.current) return; - const canvas = fabricCanvasRef.current; - const bgImage = canvas.backgroundImage; + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; - if (!bgImage) return; - - const imgScale = bgImage.scaleX; - const imgLeft = bgImage.left; - const imgTop = bgImage.top; - - // Get the transformed bounding rect (accounts for scale and rotation) const bounds = selection.getBoundingRect(true); - let bbox = { + const bbox = { x: Math.round((bounds.left - imgLeft) / imgScale), y: Math.round((bounds.top - imgTop) / imgScale), width: Math.round(bounds.width / imgScale), @@ -465,8 +567,8 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }; let selectionData = null; + const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle'); - // For lasso, we need to transform the points based on the object's transformation if (type === 'lasso' && lassoPoints.current.length > 0) { const matrix = selection.calcTransformMatrix(); const transformedPoints = lassoPoints.current.map(p => { @@ -475,14 +577,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { matrix ); return [ - Math.round((transformed.x - bounds.left) / imgScale), - Math.round((transformed.y - bounds.top) / imgScale), + Math.round((transformed.x - imgLeft) / imgScale) - bbox.x, + Math.round((transformed.y - imgTop) / imgScale) - bbox.y, ]; }); selectionData = { points: transformedPoints }; } - onSelectionChange({ + onSelectionChange?.({ type, bbox, selectionData, @@ -490,10 +592,12 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }; const clearSelection = () => { - if (currentSelection && fabricCanvasRef.current) { - fabricCanvasRef.current.remove(currentSelection); + const canvas = fabricCanvasRef.current; + const sel = currentSelectionRef.current; + if (sel && canvas) { + canvas.remove(sel); setCurrentSelection(null); - onSelectionChange(null); + onSelectionChange?.(null); } }; @@ -501,17 +605,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
{currentSelection && ( - <> -
- Click selection to move/resize/rotate -
- - + )}
); -}; +}); + +ImageCanvas.displayName = 'ImageCanvas'; export default ImageCanvas;