Image Annotation Component Usage
Overview
@frank17008/image-annotation is a React + TypeScript image annotation component with Canvas 2D. Supports rectangle, circle, arrow, text, and freehand tools with undo/redo, drag, export, keyboard shortcuts, zoom/pan, and DPR-aware rendering.
Package location: packages/image-annotation/src/
Published path: @frank17008/image-annotation
When to Use
Trigger phrases:
- "How to integrate image annotation component"
- "Add annotation to my React app"
- "Canvas blank even though image loads"
- "Annotations not saving"
- "Custom image upload with annotation"
- "Controlled mode not working"
- "HiDPI/Retina display blurry"
Installation
pnpm add @frank17008/image-annotation
# or
npm install @frank17008/image-annotation
Basic Integration
Minimal Setup
import { useState } from 'react';
import { ImageAnnotation, type Annotation } from '@frank17008/image-annotation';
import '@frank17008/image-annotation/dist/index.css';
export default function App() {
const [annotations, setAnnotations] = useState<Annotation[]>([]);
return (
<div style={{ height: 600 }}>
<ImageAnnotation
src="https://example.com/image.jpg"
onChange={setAnnotations}
/>
</div>
);
}
Critical: Parent container MUST have explicit height. Without it, canvas has 0×0 dimensions → blank canvas.
Type Import
Always use explicit type import for TypeScript:
import type { Annotation, ToolType } from '@frank17008/image-annotation';
// or
import { type Annotation, type ToolType } from '@frank17008/image-annotation';
Controlled vs Uncontrolled Mode
Uncontrolled (Default)
<ImageAnnotation
src="image.jpg"
onChange={(annotations) => console.log(annotations)}
/>
The component manages its own state internally.
Controlled (External State)
const [myAnnotations, setMyAnnotations] = useState<Annotation[]>([]);
<ImageAnnotation
src="image.jpg"
value={myAnnotations}
onChange={setMyAnnotations}
/>
Rules:
value→ external annotations (read-only for component)onChange→ called when annotations change- If both
valueandonChangeprovided → controlled mode - If neither → uncontrolled mode
- If only
valuewithoutonChange→ React warning
Sync Controlled to Uncontrolled
<ImageAnnotation
src={src}
value={annotations}
onChange={(newAnnotations) => {
setAnnotations(newAnnotations);
// Save to backend, localStorage, etc.
saveToBackend(newAnnotations);
}}
/>
Custom Image Upload
Pattern 1: External Button
import { useState, useRef } from 'react';
import { ImageAnnotation } from '@frank17008/image-annotation';
export function ImageUploader() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === 'string') {
setImageSrc(result); // base64 string
}
};
reader.readAsDataURL(file);
e.target.value = ''; // reset for same file re-select
};
return (
<div style={{ height: 600 }}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button onClick={() => fileInputRef.current?.click()}>
Upload Image
</button>
{imageSrc && (
<ImageAnnotation
src={imageSrc}
onChange={() => {}}
/>
)}
</div>
);
}
Pattern 2: Component's Upload Button
const handleUpload = () => fileInputRef.current?.click();
<ImageAnnotation
src={imageSrc}
onChange={setAnnotations}
onUpload={handleUpload}
/>
The component displays an upload button in toolbar when onUpload is provided.
Annotation Types Reference
// Rectangle annotation
{ type: 'rectangle', x: number, y: number, width: number, height: number, color: string, lineWidth?: number }
// Circle annotation
{ type: 'circle', x: number, y: number, radius: number, color: string, lineWidth?: number }
// Arrow annotation
{ type: 'arrow', x: number, y: number, toX: number, toY: number, color: string, lineWidth?: number }
// Text annotation
{ type: 'text', x: number, y: number, text: string, color: string, lineWidth?: number }
// Freehand annotation
{ type: 'freehand', points: Array<{ x: number, y: number }>, color: string, lineWidth?: number }
Type Export
import type {
Annotation,
RectangleAnnotation,
CircleAnnotation,
ArrowAnnotation,
TextAnnotation,
FreehandAnnotation,
ToolType,
Point,
} from '@frank17008/image-annotation';
Props API
| Prop | Type | Required | Description |
|---|---|---|---|
src | string | ✅ | Image URL or base64 |
value | Annotation[] | ❌ | Controlled annotations |
onChange | (annotations: Annotation[]) => void | ❌ | Annotation change callback |
className | string | ❌ | Container className |
onUpload | () => void | ❌ | Upload button callback |
Zoom and Pan
Programmatic Zoom
const handleZoomIn = () => {
// Component doesn't expose zoom API directly
// Use keyboard shortcuts: Ctrl++
};
// Or via keyboard shortcuts (user must press):
// - Ctrl++ : Zoom in
// - Ctrl+- : Zoom out
// - Ctrl+0 : Reset zoom
Space + Drag Pan
Hold Space + drag to pan the canvas.
Zoom Ratio Display
The toolbar shows current zoom percentage (e.g., "100%", "150%").
Exporting Annotations
On Change Callback
<ImageAnnotation
src={src}
onChange={(annotations) => {
// Save to database
await saveAnnotations(imageId, annotations);
// Or export as JSON
const json = JSON.stringify(annotations, null, 2);
download(json, 'annotations.json', 'application/json');
}}
/>
Export Image with Annotations
Use the toolbar's export button, or programmatically:
import { download } from '@frank17008/image-annotation';
// Not currently exported - use toolbar button
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Delete | Delete selected annotation |
| Escape | Cancel text input |
| Ctrl+Z | Undo |
| Ctrl+Y / Ctrl+Shift+Z | Redo |
| Ctrl++ / Ctrl+= | Zoom in |
| Ctrl+- | Zoom out |
| Ctrl+0 | Reset zoom |
| Space + Drag | Pan canvas |
Styling Customization
CSS Variables
:root {
--annotation-primary: #FF0000;
--annotation-toolbar-bg: #FFFFFF;
}
Container Styling
<ImageAnnotation
src={src}
className="my-annotation-container"
/>
.my-annotation-container {
border: 1px solid #ddd;
border-radius: 8px;
}
Debugging Common Issues
Issue 1: Canvas Completely Blank
Symptoms:
- Image loads successfully (network request completes)
- Canvas area is empty/white
Root Causes and Solutions:
| Cause | Diagnosis | Fix |
|---|---|---|
| Parent has no height | Container has height: auto or no height set | Set explicit height: style={{ height: 600 }} |
| DPR mismatch | Retina/HiDPI display, canvas looks tiny | Component handles DPR automatically |
| Image load error | Check browser console for CORS errors | Add crossOrigin="anonymous" to server |
| Zero canvas size | Check canvas element dimensions | Ensure parent has explicit dimensions |
Issue 2: Annotations Not Saving
Symptoms:
- Draw annotations but array remains empty
onChangecallback never fires
Root Causes and Solutions:
| Cause | Diagnosis | Fix |
|---|---|---|
| Stale state | Using prev => prev incorrectly | Pass new array directly: setAnnotations(newValue) |
| Missing onChange | No callback provided | Add onChange={setAnnotations} |
| Controlled conflict | Both value and internal state | Provide onChange with value |
Correct pattern:
// WRONG - stale closure
onChange={(annotations) => {
setAnnotations((prev) => [...prev, ...annotations]);
}}
// CORRECT - direct assignment
onChange={setAnnotations}
Issue 3: Blurry on HiDPI/Retina Displays
Diagnosis:
- Canvas appears blurry or pixelated
- Only happens on Retina displays
Root Cause: Missing devicePixelRatio scaling
Fix: The component handles DPR automatically since v1.x. Ensure:
- CSS import is included
- Parent container has proper dimensions
Issue 4: High-DPI Canvas Blank
Symptoms:
- Works on standard displays
- Blank on Retina/HiDPI
Root Cause: Canvas dimensions not scaled by devicePixelRatio
Diagnosis:
// In browser console
const canvas = document.querySelector('canvas');
console.log('Canvas size:', canvas.width, canvas.height);
console.log('Display size:', canvas.style.width, canvas.style.height);
console.log('DPR:', window.devicePixelRatio);
If canvas.width === canvas.style.width (not multiplied by DPR), this is the bug.
Issue 5: Text Annotation Not Updating
Symptoms:
- Text tool selected
- Click to add text
- Text doesn't appear or doesn't save
Solution: Press Enter or click outside to commit text input.
Issue 6: Undo/Redo Not Working
Symptoms:
- Press Ctrl+Z but nothing happens
Root Cause: No history to undo (first annotation can't be undone)
Issue 7: SSR/Next.js Issues
Symptoms:
- "window is not defined"
- Canvas only renders client-side
Solution:
'use client';
import dynamic from 'next/dynamic';
const ImageAnnotation = dynamic(
() => import('@frank17008/image-annotation').then((mod) => mod.default),
{ ssr: false }
);
Build Commands
pnpm build:lib # Build package to dist/
pnpm dev:lib # Watch mode for library
pnpm start # Run demo app
pnpm build:all # Build everything
Dependencies
- React 18+
- TypeScript 4.5+
- Canvas 2D API
Common Mistakes
❌ Forgetting CSS Import
// MISSING - component won't render correctly
import { ImageAnnotation } from '@frank17008/image-annotation';
// REQUIRED
import '@frank17008/image-annotation/dist/index.css';
❌ Parent Without Height
// WRONG - canvas has 0x0
<div><ImageAnnotation src="..." /></div>
// CORRECT - explicit height
<div style={{ height: 600 }}><ImageAnnotation src="..." /></div>
❌ Wrong Type Import
// WRONG
import { Annotation } from '@frank17008/image-annotation';
// CORRECT
import type { Annotation } from '@frank17008/image-annotation';
❌ Not Linking onChange with value
// WRONG - controlled without onChange (React warning)
<ImageAnnotation src="..." value={annotations} />
// CORRECT - controlled mode
<ImageAnnotation
src="..."
value={annotations}
onChange={setAnnotations}
/>
Performance Tips
Large Images
For images > 5MB:
- Resize before passing to component
- Use appropriate container dimensions
- Consider lazy loading
Many Annotations (>100)
The component redraws on each change. For performance:
- Reduce lineWidth
- Simplify freehand paths (reduce point count)