From d898106368ac6f3c9826f4b4e9f9be15074dbeca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 04:51:45 +0000 Subject: [PATCH] Add core editing UX improvements - Make canvas use full viewport height for larger image display - Add download button to export edited images - Implement Undo/Redo with Ctrl+Z / Ctrl+Y keyboard shortcuts - Add transform controls for selections (move, resize, rotate) - Add warn on reload to prevent data loss - Make project naming optional, auto-generate from filename --- frontend/src/App.css | 17 +- frontend/src/App.jsx | 133 ++++++++++++-- frontend/src/components/Controls.css | 13 ++ frontend/src/components/Controls.jsx | 11 ++ frontend/src/components/ImageCanvas.css | 19 +- frontend/src/components/ImageCanvas.jsx | 224 +++++++++++++++++++++--- 6 files changed, 377 insertions(+), 40 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 92c87d5..bf7c1fc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -69,6 +69,18 @@ margin-top: 4px; } +.optional-field label::after { + content: ''; +} + +.optional-field { + opacity: 0.7; +} + +.optional-field:focus-within { + opacity: 1; +} + .create-project-btn { background-color: #0066ff; color: white; @@ -86,7 +98,8 @@ display: grid; grid-template-columns: 1fr 400px; gap: 20px; - min-height: 600px; + min-height: calc(100vh - 180px); + height: calc(100vh - 180px); } @media (max-width: 1200px) { @@ -98,6 +111,8 @@ .left-panel { display: flex; flex-direction: column; + height: 100%; + min-height: 0; } .right-panel { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a2c1882..23184ed 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import ImageCanvas from './components/ImageCanvas'; import Controls from './components/Controls'; import History from './components/History'; @@ -19,11 +19,13 @@ function App() { const [error, setError] = useState(null); const [projectName, setProjectName] = useState(''); const [showProjectInput, setShowProjectInput] = useState(true); + const [currentEditIndex, setCurrentEditIndex] = useState(-1); + const editsRef = useRef([]); // Create project and upload image const handleCreateProject = async () => { - if (!projectName.trim() || !imageFile) { - setError('Please provide a project name and select an image'); + if (!imageFile) { + setError('Please select an image'); return; } @@ -31,8 +33,13 @@ function App() { setError(null); setIsProcessing(true); + // Generate default project name from file name or timestamp + const defaultName = projectName.trim() || + imageFile.name.replace(/\.[^/.]+$/, '') || + `Project ${Date.now()}`; + // Create project - const newProject = await projectsApi.create(projectName); + const newProject = await projectsApi.create(defaultName); setProject(newProject); // Upload image @@ -58,6 +65,10 @@ function App() { try { const projectEdits = await projectsApi.getEdits(projectId); setEdits(projectEdits); + editsRef.current = projectEdits; + // Set index to latest completed edit + const completedEdits = projectEdits.filter(e => e.status === 'completed'); + setCurrentEditIndex(completedEdits.length - 1); } catch (err) { console.error('Failed to load edits:', err); } @@ -174,6 +185,93 @@ function App() { } }; + // Handle download + const handleDownload = async () => { + if (!currentImageUrl) return; + + try { + // Fetch the current image + const response = await fetch(`${currentImageUrl}?t=${Date.now()}`); + const blob = await response.blob(); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `edited-image-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + setError(`Failed to download: ${err.message}`); + } + }; + + // Handle undo (Ctrl+Z) + const handleUndo = useCallback(async () => { + if (!project || isProcessing) return; + + const completedEdits = editsRef.current.filter(e => e.status === 'completed'); + if (completedEdits.length === 0) return; + + if (currentEditIndex <= 0) { + // Revert to original + await handleReset(); + setCurrentEditIndex(-1); + } else { + // Revert to previous edit + const previousEdit = completedEdits[currentEditIndex - 1]; + await handleRevert(previousEdit.id); + setCurrentEditIndex(currentEditIndex - 1); + } + }, [project, isProcessing, currentEditIndex]); + + // Handle redo (Ctrl+Y) + const handleRedo = useCallback(async () => { + if (!project || isProcessing) return; + + const completedEdits = editsRef.current.filter(e => e.status === 'completed'); + if (currentEditIndex >= completedEdits.length - 1) return; + + const nextEdit = completedEdits[currentEditIndex + 1]; + await handleRevert(nextEdit.id); + setCurrentEditIndex(currentEditIndex + 1); + }, [project, isProcessing, currentEditIndex]); + + // Keyboard shortcut handler + useEffect(() => { + const handleKeyDown = (e) => { + // Don't trigger shortcuts when typing in input fields + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + handleRedo(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleUndo, handleRedo]); + + // Warn before leaving page when there are unsaved changes + useEffect(() => { + const handleBeforeUnload = (e) => { + if (project && edits.length > 0) { + e.preventDefault(); + e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; + return e.returnValue; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [project, edits]); + return (
@@ -191,18 +289,8 @@ function App() { {showProjectInput ? (
-

Create New Project

+

Start Editing

-
- - setProjectName(e.target.value)} - placeholder="My Photo Edit Project" - disabled={isProcessing} - /> -
Selected: {imageFile.name}

)}
+
+ + setProjectName(e.target.value)} + placeholder="Uses filename if left empty" + disabled={isProcessing} + /> +
@@ -246,6 +344,7 @@ function App() { onPromptChange={setPrompt} onFix={handleFix} onClear={() => setSelection(null)} + onDownload={handleDownload} isProcessing={isProcessing} hasSelection={!!selection} /> diff --git a/frontend/src/components/Controls.css b/frontend/src/components/Controls.css index 13c6634..6166a6d 100644 --- a/frontend/src/components/Controls.css +++ b/frontend/src/components/Controls.css @@ -119,3 +119,16 @@ .clear-btn:hover:not(:disabled) { background-color: #cc0000; } + +.download-btn { + width: 100%; + background-color: #00aa00; + color: white; + padding: 14px 20px; + font-size: 16px; + font-weight: 600; +} + +.download-btn:hover:not(:disabled) { + background-color: #00cc00; +} diff --git a/frontend/src/components/Controls.jsx b/frontend/src/components/Controls.jsx index 8d078cb..e72325a 100644 --- a/frontend/src/components/Controls.jsx +++ b/frontend/src/components/Controls.jsx @@ -12,6 +12,7 @@ const Controls = ({ onPromptChange, onFix, onClear, + onDownload, isProcessing, hasSelection, }) => { @@ -114,6 +115,16 @@ const Controls = ({ Clear Selection
+ +
+ +
); }; diff --git a/frontend/src/components/ImageCanvas.css b/frontend/src/components/ImageCanvas.css index 7613800..c5d03d1 100644 --- a/frontend/src/components/ImageCanvas.css +++ b/frontend/src/components/ImageCanvas.css @@ -2,11 +2,14 @@ position: relative; width: 100%; height: 100%; - min-height: 500px; + min-height: calc(100vh - 180px); background-color: #2a2a2a; border: 2px solid #444; border-radius: 8px; overflow: hidden; + display: flex; + align-items: center; + justify-content: center; } .canvas-container canvas { @@ -26,3 +29,17 @@ .clear-selection-btn:hover { background-color: #cc0000; } + +.selection-hint { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: #00ff00; + padding: 8px 16px; + border-radius: 4px; + font-size: 12px; + z-index: 10; + white-space: nowrap; +} diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx index f87c2a7..7df6108 100644 --- a/frontend/src/components/ImageCanvas.jsx +++ b/frontend/src/components/ImageCanvas.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { fabric } from 'fabric'; import './ImageCanvas.css'; @@ -7,6 +7,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { const fabricCanvasRef = useRef(null); const [currentSelection, setCurrentSelection] = useState(null); const [isDrawing, setIsDrawing] = useState(false); + const [isTransformMode, setIsTransformMode] = useState(false); const lassoPoints = useRef([]); useEffect(() => { @@ -23,8 +24,25 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { const handleResize = () => { const container = canvasRef.current?.parentElement; if (container) { - canvas.setWidth(container.clientWidth); - canvas.setHeight(Math.min(container.clientHeight, 800)); + const width = container.clientWidth; + const height = container.clientHeight; + canvas.setWidth(width); + canvas.setHeight(height); + + // Re-center and rescale the image if it exists + const bgImage = canvas.backgroundImage; + if (bgImage) { + const scale = Math.min( + (width - 40) / bgImage.width, + (height - 40) / bgImage.height, + 1 + ); + bgImage.scale(scale); + bgImage.set({ + left: (width - bgImage.width * scale) / 2, + top: (height - bgImage.height * scale) / 2, + }); + } canvas.renderAll(); } }; @@ -50,11 +68,13 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { fabric.Image.fromURL(cacheBustedUrl, (img) => { canvas.clear(); - // Scale image to fit canvas + // Scale image to fit canvas with padding + const padding = 40; + const availableWidth = canvas.width - padding; + const availableHeight = canvas.height - padding; const scale = Math.min( - canvas.width / img.width, - canvas.height / img.height, - 1 + availableWidth / img.width, + availableHeight / img.height ); img.scale(scale); @@ -80,17 +100,21 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { const canvas = fabricCanvasRef.current; - // Clear previous selection + // 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 canvas.off('mouse:down'); canvas.off('mouse:move'); canvas.off('mouse:up'); + canvas.off('object:modified'); if (selectionMode === 'rectangle') { setupRectangleMode(canvas); @@ -105,6 +129,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { let rect, isDown, startX, startY; canvas.on('mouse:down', (e) => { + // If clicking on existing selection, enable transform mode + if (e.target && e.target === currentSelection) { + setIsTransformMode(true); + 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); + setCurrentSelection(null); + } + isDown = true; const pointer = canvas.getPointer(e.e); startX = pointer.x; @@ -119,6 +160,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { stroke: '#00ff00', strokeWidth: 2, selectable: true, + hasControls: true, + hasBorders: true, + lockRotation: false, + cornerColor: '#00ff00', + cornerSize: 10, + transparentCorners: false, + borderColor: '#00ff00', + borderScaleFactor: 2, }); canvas.add(rect); @@ -126,7 +175,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:move', (e) => { - if (!isDown) return; + if (!isDown || isTransformMode) return; const pointer = canvas.getPointer(e.e); const width = pointer.x - startX; @@ -143,8 +192,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:up', () => { - isDown = false; - updateSelection(rect, 'rectangle'); + if (isDown && !isTransformMode) { + isDown = false; + canvas.setActiveObject(rect); + updateSelection(rect, 'rectangle'); + } + }); + + // Update selection when object is modified (moved, scaled, rotated) + canvas.on('object:modified', (e) => { + if (e.target && e.target === currentSelection) { + updateTransformedSelection(e.target, 'rectangle'); + } }); }; @@ -152,6 +211,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { let ellipse, isDown, startX, startY; canvas.on('mouse:down', (e) => { + // If clicking on existing selection, enable transform mode + if (e.target && e.target === currentSelection) { + setIsTransformMode(true); + 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); + setCurrentSelection(null); + } + isDown = true; const pointer = canvas.getPointer(e.e); startX = pointer.x; @@ -166,6 +242,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { stroke: '#00ff00', strokeWidth: 2, selectable: true, + hasControls: true, + hasBorders: true, + lockRotation: false, + cornerColor: '#00ff00', + cornerSize: 10, + transparentCorners: false, + borderColor: '#00ff00', + borderScaleFactor: 2, }); canvas.add(ellipse); @@ -173,7 +257,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:move', (e) => { - if (!isDown) return; + if (!isDown || isTransformMode) return; const pointer = canvas.getPointer(e.e); const rx = Math.abs(pointer.x - startX) / 2; @@ -190,8 +274,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:up', () => { - isDown = false; - updateSelection(ellipse, 'ellipse'); + if (isDown && !isTransformMode) { + isDown = false; + canvas.setActiveObject(ellipse); + updateSelection(ellipse, 'ellipse'); + } + }); + + // Update selection when object is modified (moved, scaled, rotated) + canvas.on('object:modified', (e) => { + if (e.target && e.target === currentSelection) { + updateTransformedSelection(e.target, 'ellipse'); + } }); }; @@ -199,6 +293,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { let line, points = []; canvas.on('mouse:down', (e) => { + // If clicking on existing selection, enable transform mode + if (e.target && e.target === currentSelection) { + setIsTransformMode(true); + 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); + setCurrentSelection(null); + } + setIsDrawing(true); const pointer = canvas.getPointer(e.e); points = [{ x: pointer.x, y: pointer.y }]; @@ -208,6 +319,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { stroke: '#00ff00', strokeWidth: 2, selectable: true, + hasControls: true, + hasBorders: true, + lockRotation: false, + cornerColor: '#00ff00', + cornerSize: 10, + transparentCorners: false, + borderColor: '#00ff00', + borderScaleFactor: 2, }); canvas.add(line); @@ -215,7 +334,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:move', (e) => { - if (!isDrawing) return; + if (!isDrawing || isTransformMode) return; const pointer = canvas.getPointer(e.e); points.push({ x: pointer.x, y: pointer.y }); @@ -225,9 +344,19 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); canvas.on('mouse:up', () => { - setIsDrawing(false); - lassoPoints.current = points; - updateSelection(line, 'lasso'); + if (isDrawing && !isTransformMode) { + setIsDrawing(false); + lassoPoints.current = points; + canvas.setActiveObject(line); + updateSelection(line, 'lasso'); + } + }); + + // Update selection when object is modified (moved, scaled, rotated) + canvas.on('object:modified', (e) => { + if (e.target && e.target === currentSelection) { + updateTransformedSelection(e.target, 'lasso'); + } }); }; @@ -285,6 +414,54 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => { }); }; + // Update selection after transformation (move, scale, rotate) + const updateTransformedSelection = (selection, type) => { + if (!selection || !fabricCanvasRef.current) return; + + const canvas = fabricCanvasRef.current; + const bgImage = canvas.backgroundImage; + + 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 = { + 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; + + // 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 => { + const transformed = fabric.util.transformPoint( + new fabric.Point(p.x, p.y), + matrix + ); + return [ + Math.round((transformed.x - bounds.left) / imgScale), + Math.round((transformed.y - bounds.top) / imgScale), + ]; + }); + selectionData = { points: transformedPoints }; + } + + onSelectionChange({ + type, + bbox, + selectionData, + }); + }; + const clearSelection = () => { if (currentSelection && fabricCanvasRef.current) { fabricCanvasRef.current.remove(currentSelection); @@ -297,9 +474,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
{currentSelection && ( - + <> +
+ Click selection to move/resize/rotate +
+ + )}
);