Once a startup came to us wanting to embed PDF annotation into a web application. They used an SVG overlay and experienced lag on a 200-page document — every stroke took a second to respond. We migrated them to Canvas with retained mode (Fabric.js) — and rendering speed increased by 8x, and users stopped complaining about lag. Over 5+ years, we have implemented over 50 canvas tools: from simple brushes to full-fledged diagram editors.
Regardless of the scenario — freehand drawing, document annotation, or diagrams — the basic architecture boils down to choosing between immediate mode and retained mode. Each approach has its trade-offs, and we will help you choose the right one for your tasks.
Technical Challenges in Developing Canvas Tools
Freehand drawing with pressure sensitivity. Not all browsers correctly transmit pen pressure — we have to emulate line width through pen speed. In 80% of cases, the problem is solved in a day: add a polyfill for Pointer Events and calibrate the algorithm for specific devices (Windows Ink, iPad, Wacom).
PDF annotation. Annotation coordinates must be tied to the page, not the screen. When zooming and scrolling, positions are recalculated with respect to the current scale. We use scalable layers and intercept transform events in Konva.js.
Export validation. Annotations must precisely overlay the original. We first render the PDF as an image, then overlay the canvas with annotations — this eliminates DPI discrepancies.
Why Retained Mode is Preferable for Annotations?
Immediate mode (pure Canvas 2D) is fast but requires manual hit-testing and scene management. Retained mode (Fabric.js, Konva.js) provides an object model: each shape is an object that can be selected, moved, or modified. For editors with undo/redo and export, retained mode cuts development time in half. Additionally, retained mode is more convenient for teamwork: changes in the object model are easily synchronized over WebSocket — enabling online collaborative drawing.
| Characteristic | Immediate mode | Retained mode |
|---|---|---|
| Performance | High (low overhead) | Medium (depends on object count) |
| Object model | None, written manually | Built-in (shapes, groups) |
| Undo/Redo | Bulky (pixel strings) | Simple (object snapshots) |
| Hit-testing | Coordinate math | Built-in (based on bounding boxes) |
| Suitable for | Freehand, brushes, elastic lines | Shapes, text, object selection |
How We Implement Annotation with Konva.js
We use Konva.js 9 with React-Konva. Each annotation is an object of class Rect, Line, Circle, Text, or Arrow. The user selects a tool and draws on the stage — the object is added to an array and rendered. Selection, moving, and editing are built-in.
import { Stage, Layer, Line, Rect, Circle, Text, Transformer } from 'react-konva';
import Konva from 'konva';
type AnnotationType = 'line' | 'rect' | 'circle' | 'arrow' | 'text';
interface Annotation {
id: string;
type: AnnotationType;
points?: number[];
x?: number;
y?: number;
width?: number;
height?: number;
text?: string;
color: string;
}
function AnnotationTool({ backgroundImage }: { backgroundImage: string }) {
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTool, setActiveTool] = useState<AnnotationType>('rect');
const [isDrawing, setIsDrawing] = useState(false);
const stageRef = useRef<Konva.Stage>(null);
function getRelativePosition() {
const stage = stageRef.current!;
const pos = stage.getPointerPosition()!;
return { x: pos.x, y: pos.y };
}
function handleMouseDown() {
setSelectedId(null);
const pos = getRelativePosition();
const newAnnotation: Annotation = {
id: crypto.randomUUID(),
type: activeTool,
color: '#ef4444',
x: pos.x,
y: pos.y,
width: 0,
height: 0,
};
if (activeTool === 'line') {
newAnnotation.points = [pos.x, pos.y, pos.x, pos.y];
}
setAnnotations((prev) => [...prev, newAnnotation]);
setIsDrawing(true);
}
function handleMouseMove() {
if (!isDrawing) return;
const pos = getRelativePosition();
const lastIndex = annotations.length - 1;
const last = annotations[lastIndex];
const updated = { ...last };
if (activeTool === 'line') {
updated.points = [last.points![0], last.points![1], pos.x, pos.y];
} else {
updated.width = pos.x - last.x!;
updated.height = pos.y - last.y!;
}
setAnnotations((prev) => [...prev.slice(0, lastIndex), updated]);
}
function handleMouseUp() {
setIsDrawing(false);
}
return (
<Stage
ref={stageRef}
width={800}
height={600}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<Layer>
{annotations.map((ann) => {
if (ann.type === 'rect') {
return (
<Rect
key={ann.id}
x={ann.x}
y={ann.y}
width={ann.width}
height={ann.height}
stroke={ann.color}
strokeWidth={2}
fill="transparent"
onClick={() => setSelectedId(ann.id)}
draggable={selectedId === ann.id}
/>
);
}
if (ann.type === 'line') {
return (
<Line
key={ann.id}
points={ann.points}
stroke={ann.color}
strokeWidth={2}
lineCap="round"
/>
);
}
return null;
})}
</Layer>
</Stage>
);
}
How to Implement Undo/Redo in a Canvas Editor?
For undo and redo we use a state stack. Each operation creates a snapshot of the current annotations and places it in history. Branching is supported — if a new action is taken after an undo, the history is truncated.
function useUndoRedo<T>(initialState: T) {
const [history, setHistory] = useState<T[]>([initialState]);
const [cursor, setCursor] = useState(0);
const current = history[cursor];
function push(newState: T) {
// Truncate history after current position (branching)
const newHistory = [...history.slice(0, cursor + 1), newState];
setHistory(newHistory);
setCursor(newHistory.length - 1);
}
function undo() {
if (cursor > 0) setCursor((c) => c - 1);
}
function redo() {
if (cursor < history.length - 1) setCursor((c) => c + 1);
}
return { current, push, undo, redo, canUndo: cursor > 0, canRedo: cursor < history.length - 1 };
}
Canvas Tool Development Process
| Stage | Description | Timeline |
|---|---|---|
| Analysis | Study scenarios: freehand, annotation, diagrams. Determine serialization format (JSON, SVG). | 1 day |
| Design | Choose stack (Canvas 2D, Konva, Fabric). Design object model and layer structure. | 1-2 days |
| Implementation | Develop tools (brush, shapes, text). Integrate undo/redo, export, pointer support. | 3-5 days |
| Testing | Test on different devices (iPad, Windows, Android). Fix bugs with touch events and pressure. | 1-2 days |
| Deployment | Document API, configure build, train client's team. | 1 day |
How We Export the Annotated Image
The background (PDF or image) is rendered as the first layer, annotations as the second. Then the entire stage canvas is converted to Blob via toBlob(). HiDPI is considered: multiply dimensions by window.devicePixelRatio.
function exportAnnotated(stage: Konva.Stage, bgImage: HTMLImageElement) {
const canvas = document.createElement('canvas');
canvas.width = stage.width();
canvas.height = stage.height();
const ctx = canvas.getContext('2d')!;
// First the background
ctx.drawImage(bgImage, 0, 0);
// Overlay annotations from Konva
const stageCanvas = stage.toCanvas();
ctx.drawImage(stageCanvas, 0, 0);
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob!);
const a = document.createElement('a');
a.href = url;
a.download = 'annotated.png';
a.click();
URL.revokeObjectURL(url);
});
}
Checklist of Common Mistakes in Canvas Tool Development
-
touchAction: nonenot set — touch events are intercepted by the browser. - Coordinates not scaled on window resize — use resize handler with scale recalculation.
- Undo/Redo stores full states (full snapshots) — for large documents memory can grow; in production use Command pattern with closures.
- Export to PNG without considering devicePixelRatio — image is blurry on Retina.
-
pointercancelevent not handled — gesture interruption leaves an artifact.
Timeline and What's Included
Timeline: basic tool with brush and shapes — from 3 to 5 days. PDF annotation with coordinate preservation — from 7 to 10 days. Cost is calculated individually after analyzing your technical specification. Thanks to architecture optimization, you can save up to 40% of development time.
What's included:
- Source code with comments
- API documentation (main methods and events)
- Deployment instructions
- 2 hours of training for the client's team
- 30-day warranty on bug fixes
Why choose us: 5+ years of experience in canvas solutions development, 50+ implemented projects, certified React and Node.js developers. Contact us for a consultation on Canvas architecture — we'll respond within a day.
For reference: Canvas API is part of the WHATWG HTML Living Standard, retained mode is a graphics programming paradigm.







