Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
138 changes: 110 additions & 28 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}
}

Expand All @@ -59,8 +64,8 @@ function saveLabels(labels: string[]): void {
function App(): JSX.Element {
const [currentProject, setCurrentProject] = useState<Project | null>(null);
const [toolMode, setToolMode] = useState<ToolMode>('draw');
const [labels, setLabels] = useState<string[]>(loadLabels);
const [currentLabel, setCurrentLabel] = useState(labels[0] ?? 'product');
const [labels, setLabels] = useState<string[]>(DEFAULT_LABELS);
const [currentLabel, setCurrentLabel] = useState<string>('');
const [showLabelManager, setShowLabelManager] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [darkMode, setDarkMode] = useState(() => {
Expand Down Expand Up @@ -103,19 +108,25 @@ function App(): JSX.Element {
selectedId,
loading: annotationsLoading,
error: annotationsError,
canUndo,
loadAnnotations,
addAnnotation,
updateAnnotation,
deleteAnnotation,
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();
},
Expand All @@ -126,6 +137,8 @@ function App(): JSX.Element {
const handleCloseProject = useCallback(async (): Promise<void> => {
await closeProject();
setCurrentProject(null);
setLabels(DEFAULT_LABELS);
setCurrentLabel('');
setDoneStatus({});
setDoneCount(0);
}, []);
Expand Down Expand Up @@ -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 => {
Expand All @@ -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':
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -508,7 +546,9 @@ function App(): JSX.Element {
{/* Left sidebar - Images */}
<aside className="flex w-64 flex-col border-r border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300">Images</h2>
<h2 className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
Images ({images.length})
</h2>
<ImageUpload onUpload={uploadImages} disabled={loading} />
</div>
<div className="flex-1 overflow-y-auto">
Expand Down Expand Up @@ -617,20 +657,62 @@ function App(): JSX.Element {
</div>

{/* Status bar */}
<footer className="border-t border-gray-200 bg-gray-50 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<footer className="border-t border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 px-4 py-2 text-xs dark:border-gray-700 dark:from-gray-800 dark:to-gray-900">
<div className="flex items-center justify-between">
<span>
{loading ? 'Loading...' : 'Ready'} |{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">D</kbd> Draw{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">S</kbd> Select{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">Space</kbd> Pan{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">←</kbd>
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">→</kbd> Navigate{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">Del</kbd> Delete{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">Esc</kbd> Deselect{' '}
<kbd className="rounded bg-gray-200 px-1 dark:bg-gray-700">1-9</kbd> Labels
</span>
<span>{currentImage && `${currentImage}`}</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
D
</kbd>
<span className="text-gray-500 dark:text-gray-400">Draw</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
S
</kbd>
<span className="text-gray-500 dark:text-gray-400">Select</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
Space
</kbd>
<span className="text-gray-500 dark:text-gray-400">Pan</span>
</div>
<div className="flex items-center gap-1">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</kbd>
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</kbd>
<span className="ml-0.5 text-gray-500 dark:text-gray-400">Navigate</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
Del
</kbd>
<span className="text-gray-500 dark:text-gray-400">Delete</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
⌘Z
</kbd>
<span className="text-gray-500 dark:text-gray-400">Undo</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
Esc
</kbd>
<span className="text-gray-500 dark:text-gray-400">Deselect</span>
</div>
<div className="flex items-center gap-1.5">
<kbd className="rounded-md border border-gray-300 bg-white px-1.5 py-0.5 font-mono text-[10px] font-medium text-gray-600 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
1-9
</kbd>
<span className="text-gray-500 dark:text-gray-400">Labels</span>
</div>
</div>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentImage ?? ''}</span>
</div>
</footer>

Expand Down
22 changes: 16 additions & 6 deletions frontend/src/components/canvas/AnnotationCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { Annotation, BoundingBox, DrawingRect, ToolMode } from '@/types';
import { getLabelColor } from '@/lib/constants';

/** Edge pan threshold in pixels (distance from edge to trigger auto-pan) */
const EDGE_PAN_THRESHOLD = 50;
const EDGE_PAN_THRESHOLD = 15;
/** Auto-pan speed in pixels per frame */
const EDGE_PAN_SPEED = 15;
const EDGE_PAN_SPEED = 4;

interface AnnotationCanvasProps {
imageUrl: string | null;
Expand Down Expand Up @@ -135,7 +135,10 @@ export function AnnotationCanvas({
const stage = stageRef.current;
if (!stage) return;

const scaleBy = 1.1;
// Use delta-proportional zoom for smooth trackpad pinch-to-zoom
// Clamp deltaY to avoid extreme zoom jumps from fast scroll wheels
const deltaY = Math.abs(e.evt.deltaY);
const scaleBy = 1 + Math.min(deltaY, 100) * 0.002;
const oldZoom = zoom;
const pointer = stage.getPointerPosition();

Expand Down Expand Up @@ -390,7 +393,7 @@ export function AnnotationCanvas({
startAutoPanIfNeeded(screenPos);
};

const handleMouseUp = (): void => {
const handleMouseUp = useCallback((): void => {
// Stop any auto-pan in progress
stopAutoPan();
lastMousePosRef.current = null;
Expand Down Expand Up @@ -423,7 +426,15 @@ export function AnnotationCanvas({

setIsDrawing(false);
setDrawingRect(null);
};
}, [isPanning, isDrawing, drawingRect, image, stopAutoPan, onAddAnnotation]);

// Listen for mouseup on window to catch releases outside the canvas
useEffect(() => {
if (isDrawing || isPanning) {
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}
}, [isDrawing, isPanning, handleMouseUp]);

const handleRectClick = (e: Konva.KonvaEventObject<MouseEvent | Event>, id: string): void => {
if (toolMode === 'select') {
Expand Down Expand Up @@ -648,7 +659,6 @@ export function AnnotationCanvas({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onClick={handleStageClick}
style={{ cursor: getCursor() }}
Expand Down
Loading