diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb0d60..dd4f6f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-02-05 + +### Added + +- Undo functionality (`⌘Z` / `Ctrl+Z`) to undo the last drawn bounding box +- Image count display in sidebar title (matches Labels and Annotations count display) +- Project-scoped labels: each project now maintains its own independent set of labels + +### Changed + +- Improved edge pan behavior: threshold reduced from 50px to 15px (triggers only at actual edge) +- Smoother pan speed: reduced from 15px/frame to 4px/frame for better control +- Delta-proportional zoom for trackpad pinch-to-zoom (much smoother control) +- Increased upload rate limit from 30/minute to 1000/minute for bulk image imports +- Improved status bar styling with grouped shortcuts and better spacing +- Added `BBANNOTATE_UPLOAD_RATE_LIMIT` environment variable documentation to README + +### Fixed + +- Fixed premature bounding box release when dragging near canvas edge (removed onMouseLeave handler, uses window-level mouseup instead) +- Fixed labels being shared across projects (now stored per-project in localStorage) + ## [1.0.4] - 2026-02-05 ### Fixed diff --git a/README.md b/README.md index faf441c..5af5388 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ bbannotate build-frontend # Build frontend assets (development) | `←` `→` | Navigate images | | `1-9` | Select label by index | | `Del` / `Backspace` | Delete annotation | +| `⌘Z` / `Ctrl+Z` | Undo last annotation | | `Esc` | Deselect / Cancel | ## Export Formats @@ -92,6 +93,7 @@ bbannotate build-frontend # Build frontend assets (development) |----------|-------------| | `BBANNOTATE_DATA_DIR` | Override default data directory | | `BBANNOTATE_PROJECTS_DIR` | Override default projects directory | +| `BBANNOTATE_UPLOAD_RATE_LIMIT` | Upload rate limit (default: `1000/minute`) | ## Development diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1f41b7..cc42870 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,10 +25,15 @@ import type { ToolMode, DrawingRect, BoundingBox, Project } from '@/types'; /** Default labels - empty so users define their own */ const DEFAULT_LABELS: string[] = []; -/** Load labels from localStorage or use defaults */ -function loadLabels(): string[] { +/** Get the localStorage key for a project's labels */ +function getLabelsKey(projectName: string | null): string { + return projectName ? `annotationLabels_${projectName}` : 'annotationLabels'; +} + +/** Load labels from localStorage for a specific project */ +function loadLabelsForProject(projectName: string | null): string[] { if (typeof window === 'undefined') return DEFAULT_LABELS; - const stored = localStorage.getItem('annotationLabels'); + const stored = localStorage.getItem(getLabelsKey(projectName)); if (stored) { try { const parsed = JSON.parse(stored) as unknown; @@ -46,10 +51,10 @@ function loadLabels(): string[] { return DEFAULT_LABELS; } -/** Save labels to localStorage */ -function saveLabels(labels: string[]): void { - if (typeof window !== 'undefined') { - localStorage.setItem('annotationLabels', JSON.stringify(labels)); +/** Save labels to localStorage for a specific project */ +function saveLabelsForProject(projectName: string | null, labels: string[]): void { + if (typeof window !== 'undefined' && projectName) { + localStorage.setItem(getLabelsKey(projectName), JSON.stringify(labels)); } } @@ -59,8 +64,8 @@ function saveLabels(labels: string[]): void { function App(): JSX.Element { const [currentProject, setCurrentProject] = useState(null); const [toolMode, setToolMode] = useState('draw'); - const [labels, setLabels] = useState(loadLabels); - const [currentLabel, setCurrentLabel] = useState(labels[0] ?? 'product'); + const [labels, setLabels] = useState(DEFAULT_LABELS); + const [currentLabel, setCurrentLabel] = useState(''); const [showLabelManager, setShowLabelManager] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); const [darkMode, setDarkMode] = useState(() => { @@ -103,6 +108,7 @@ function App(): JSX.Element { selectedId, loading: annotationsLoading, error: annotationsError, + canUndo, loadAnnotations, addAnnotation, updateAnnotation, @@ -110,12 +116,17 @@ function App(): JSX.Element { clearAnnotations, selectAnnotation, updateLocalBbox, + undoLastAnnotation, } = useAnnotations(); // Handle project open const handleOpenProject = useCallback( (project: Project): void => { setCurrentProject(project); + // Load labels for this project + const projectLabels = loadLabelsForProject(project.name); + setLabels(projectLabels); + setCurrentLabel(projectLabels[0] ?? ''); // Refresh images when project opens refreshImages(); }, @@ -126,6 +137,8 @@ function App(): JSX.Element { const handleCloseProject = useCallback(async (): Promise => { await closeProject(); setCurrentProject(null); + setLabels(DEFAULT_LABELS); + setCurrentLabel(''); setDoneStatus({}); setDoneCount(0); }, []); @@ -201,6 +214,13 @@ function App(): JSX.Element { selectAnnotation(null); }, [selectAnnotation]); + // Handle undo (exposed for keyboard shortcut) + const handleUndo = useCallback((): void => { + if (canUndo) { + undoLastAnnotation(); + } + }, [canUndo, undoLastAnnotation]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { @@ -214,9 +234,19 @@ function App(): JSX.Element { } switch (e.key) { + case 'z': + case 'Z': + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + handleUndo(); + } + break; case 's': case 'S': - setToolMode('select'); + // Don't trigger select mode if Cmd/Ctrl is pressed (e.g., Cmd+S for save) + if (!e.metaKey && !e.ctrlKey) { + setToolMode('select'); + } break; case 'd': case 'D': @@ -259,7 +289,15 @@ function App(): JSX.Element { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [prevImage, nextImage, handleDeleteSelected, handleDeselect, labels, currentLabel]); + }, [ + prevImage, + nextImage, + handleDeleteSelected, + handleDeselect, + handleUndo, + labels, + currentLabel, + ]); const handleAddAnnotation = useCallback( (rect: DrawingRect, imageWidth: number, imageHeight: number) => { @@ -376,13 +414,13 @@ function App(): JSX.Element { const handleLabelsChange = useCallback( (newLabels: string[]): void => { setLabels(newLabels); - saveLabels(newLabels); + saveLabelsForProject(currentProject?.name ?? null, newLabels); // If current label was removed, switch to first label if (!newLabels.includes(currentLabel) && newLabels.length > 0) { - setCurrentLabel(newLabels[0] ?? 'product'); + setCurrentLabel(newLabels[0] ?? ''); } }, - [currentLabel] + [currentLabel, currentProject?.name] ); const handleDeleteImage = useCallback( @@ -508,7 +546,9 @@ function App(): JSX.Element { {/* Left sidebar - Images */}