diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx index 21d3c4f..86307ac 100644 --- a/frontend/src/components/ImageCanvas.jsx +++ b/frontend/src/components/ImageCanvas.jsx @@ -1,23 +1,21 @@ -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 = ({ +const ImageCanvas = forwardRef(({ imageUrl, onSelectionChange, selectionMode, - advancedToolMode, - onAdvancedToolClick, - zoom = 1, + activeTool, + zoom = 100, onZoomChange, - externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} } -}) => { + 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 [currentZoom, setCurrentZoom] = useState(zoom); + const currentSelectionRef = useRef(null); const lassoPoints = useRef([]); const onZoomChangeRef = useRef(onZoomChange); @@ -26,17 +24,28 @@ const ImageCanvas = ({ 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; - // 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) { @@ -45,19 +54,9 @@ const ImageCanvas = ({ 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(); } @@ -102,6 +101,39 @@ const ImageCanvas = ({ }; }, []); // 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; @@ -143,186 +175,155 @@ const ImageCanvas = ({ top: ((canvas.height || 600) - 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 advanced tool mode clicks (smart-select, color-select) - useEffect(() => { - if (!fabricCanvasRef.current || !advancedToolMode) return; - - const canvas = fabricCanvasRef.current; - const bgImage = canvas.backgroundImage; - - const handleAdvancedClick = async (e) => { - if (!bgImage || !onAdvancedToolClick) return; - - const pointer = canvas.getPointer(e.e); - - // Convert canvas coordinates to image coordinates - const imgScale = bgImage.scaleX; - const imgLeft = bgImage.left; - const imgTop = bgImage.top; - - const imgX = Math.round((pointer.x - imgLeft) / imgScale); - const imgY = Math.round((pointer.y - imgTop) / imgScale); - - // Check if click is within image bounds - if (imgX < 0 || imgY < 0 || imgX > bgImage.width || imgY > bgImage.height) { - return; - } - - if (advancedToolMode === 'color-select') { - // Get pixel color at click position - const ctx = canvas.getContext('2d'); - const canvasX = pointer.x * canvas.getZoom(); - const canvasY = pointer.y * canvas.getZoom(); - - // For color picking, we need to get the color from the image - // Create a temporary canvas to read pixel color - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = bgImage.width; - tempCanvas.height = bgImage.height; - const tempCtx = tempCanvas.getContext('2d'); - - // Draw the image element to temp canvas - const imgElement = bgImage.getElement(); - tempCtx.drawImage(imgElement, 0, 0); - - const pixelData = tempCtx.getImageData(imgX, imgY, 1, 1).data; - const color = { r: pixelData[0], g: pixelData[1], b: pixelData[2] }; - - onAdvancedToolClick(imgX, imgY, color); - } else { - onAdvancedToolClick(imgX, imgY, null); - } - }; - - canvas.on('mouse:down', handleAdvancedClick); - - return () => { - canvas.off('mouse:down', handleAdvancedClick); - }; - }, [advancedToolMode, onAdvancedToolClick]); - - // Handle external selection (from smart-select or color-select) - useEffect(() => { - if (!fabricCanvasRef.current || !externalSelection?.polygon?.length) return; - - const canvas = fabricCanvasRef.current; - const bgImage = canvas.backgroundImage; - - if (!bgImage) return; - - // Clear previous selection - if (currentSelection) { - canvas.remove(currentSelection); - } - - // Convert image coordinates to canvas coordinates - const imgScale = bgImage.scaleX; - const imgLeft = bgImage.left; - const imgTop = bgImage.top; - - const canvasPoints = externalSelection.polygon.map(([x, y]) => ({ - x: x * imgScale + imgLeft, - y: y * imgScale + imgTop, - })); - - // Create polygon selection - const polygon = new fabric.Polygon(canvasPoints, { - fill: 'rgba(255, 255, 255, 0.3)', - stroke: '#00ff00', - strokeWidth: 2, - selectable: true, - hasControls: true, - hasBorders: true, - lockRotation: false, - cornerColor: '#00ff00', - cornerSize: 10, - transparentCorners: false, - borderColor: '#00ff00', - borderScaleFactor: 2, - }); - - canvas.add(polygon); - canvas.setActiveObject(polygon); - setCurrentSelection(polygon); - lassoPoints.current = canvasPoints; - - // Notify parent of selection - onSelectionChange({ - type: 'polygon', - bbox: externalSelection.bbox, - selectionData: { points: externalSelection.polygon }, - }); - - canvas.renderAll(); - }, [externalSelection]); - - // Handle selection mode changes + // Handle tool/mode changes useEffect(() => { if (!fabricCanvasRef.current) return; const canvas = fabricCanvasRef.current; - // Don't set up selection handlers if in advanced tool mode - if (advancedToolMode) return; - - // 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, advancedToolMode]); + }, [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; @@ -332,26 +333,24 @@ const ImageCanvas = ({ 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; @@ -368,39 +367,40 @@ const ImageCanvas = ({ }); 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); } @@ -414,26 +414,24 @@ const ImageCanvas = ({ 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; @@ -442,58 +440,55 @@ const ImageCanvas = ({ 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, @@ -503,16 +498,15 @@ const ImageCanvas = ({ }); 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, @@ -522,59 +516,50 @@ const ImageCanvas = ({ }); 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; @@ -601,39 +586,32 @@ const ImageCanvas = ({ 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), @@ -641,8 +619,8 @@ const ImageCanvas = ({ }; 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 => { @@ -651,14 +629,14 @@ const ImageCanvas = ({ 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, @@ -666,10 +644,12 @@ const ImageCanvas = ({ }; 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); } }; @@ -711,36 +691,15 @@ const ImageCanvas = ({ return (