How DrawDB Implements the Visual Canvas for ERD Editing: React Context Architecture Explained
DrawDB's visual canvas uses a React Context-based architecture with coordinate conversion helpers and pointer state management to enable pan, zoom, and pixel-perfect rendering of ER diagrams.
The visual canvas is the core interaction surface that makes DrawDB's entity-relationship diagram editor feel responsive and intuitive. Rather than relying on a heavy external canvas library, the drawdb-io/drawdb repository implements a lightweight, custom solution that cleanly separates view logic from rendering logic. This article breaks down how the visual canvas for ERD editing is implemented, from the central context provider down to individual diagram components.
CanvasContext: The Central State Definition
The foundation of DrawDB's canvas system is CanvasContext, defined in src/context/CanvasContext.jsx. This context establishes the default shape of all canvas-related state:
- Canvas size – width and height of the container element
- viewBox – the SVG viewport that enables panning and zooming
- Coordinate converters – functions to translate between screen pixels and diagram coordinates
- Pointer state – current mouse/touch position and cursor style
The context itself is a React createContext call that provides the structural contract. All actual state management happens inside the provider component.
CanvasContextProvider: The State Management Engine
The CanvasContextProvider component in src/context/CanvasContext.jsx is where the visual canvas comes alive. It performs five critical functions that together enable interactive ERD editing.
DOM Attachment and Size Observation
The provider renders a <div> wrapper and attaches a resize observer using useResizeObserver from usehooks-ts:
import { useResizeObserver } from 'usehooks-ts';
function CanvasContextProvider({ children, className }) {
const containerRef = useRef(null);
const { width, height } = useResizeObserver({ ref: containerRef });
// width and height remain accurate through window resizes
}
This ensures the canvas automatically adapts when the browser window changes size.
Pan and Zoom Integration
The provider consumes the useTransform hook (from src/hooks/useTransform.js) to obtain current pan (x, y offset) and zoom factor:
import { useTransform } from '../hooks/useTransform';
function CanvasContextProvider({ children }) {
const { pan, zoom, setPan, setZoom } = useTransform();
// pan.x, pan.y, zoom drive the viewBox calculation
}
These values feed directly into the SVG viewBox computation, which keeps the diagram crisp at any zoom level.
ViewBox Calculation
The provider computes the SVG viewBox string that makes panning and zooming work:
const viewBox = useMemo(() => {
const viewWidth = width / zoom;
const viewHeight = height / zoom;
const viewX = pan.x - viewWidth / 2;
const viewY = pan.y - viewHeight / 2;
return `${viewX} ${viewY} ${viewWidth} ${viewHeight}`;
}, [width, height, pan, zoom]);
This approach centers the view on the pan coordinates while scaling by the zoom factor—standard SVG techniques applied to interactive diagram editing.
Coordinate Conversion Helpers
Two essential functions bridge the gap between screen interaction and model coordinates:
| Helper | Purpose |
|---|---|
toDiagramSpace(screenPoint) |
Converts mouse pixels to diagram (model) coordinates |
toScreenSpace(diagramPoint) |
Converts model coordinates to screen pixels for rendering |
const coords = useMemo(() => ({
toDiagramSpace: ({ x, y }) => ({
x: (x - width / 2) / zoom + pan.x,
y: (y - height / 2) / zoom + pan.y,
}),
toScreenSpace: ({ x, y }) => ({
x: (x - pan.x) * zoom + width / 2,
y: (y - pan.y) * zoom + height / 2,
}),
}), [width, height, pan, zoom]);
These helpers let components work in their natural coordinate systems—tables store positions in model space, but render in screen space.
Pointer Tracking and Styling
The provider attaches pointerdown and pointermove listeners to track cursor position and expose a style setter:
const [pointerPos, setPointerPos] = useState({ x: 0, y: 0 });
const [pointerStyle, setPointerStyle] = useState('default');
const pointer = {
x: pointerPos.x,
y: pointerPos.y,
style: pointerStyle,
setStyle: setPointerStyle,
};
This enables hover effects across the entire canvas, such as changing to a crosshair when hovering over relationship lines.
useCanvas Hook: Developer Convenience
Located in src/hooks/useCanvas.js, this trivial hook simply returns the context value:
import { useContext } from 'react';
import { CanvasContext } from '../context/CanvasContext';
export const useCanvas = () => useContext(CanvasContext);
Its value is consistency—throughout the codebase, components call useCanvas() rather than importing both useContext and CanvasContext.
useTransform Hook: Pan/Zoom State Source
The useTransform hook in src/hooks/useTransform.js manages the fundamental camera state:
export function useTransform() {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
return { pan, setPan, zoom, setZoom };
}
This separation allows multiple contexts or features to share pan/zoom state without duplicating logic. The hook can be extended with bounds checking, smooth animations, or persistence without affecting the canvas context.
Integration: How Components Consume the Canvas
The top-level Workspace.jsx component (located in src/components/Workspace.jsx) wires everything together:
import { CanvasContextProvider } from '../context/CanvasContext';
import { Diagram } from './Diagram';
export default function Workspace() {
return (
<div className="workspace">
<CanvasContextProvider className="canvas-wrapper">
<Diagram />
</CanvasContextProvider>
</div>
);
}
All descendants of CanvasContextProvider can now access canvas state. Here's how a table node positions itself correctly:
import { useCanvas } from '../hooks';
export default function TableNode({ table }) {
const { coords } = useCanvas();
// table.x, table.y are model coordinates
const screenPos = coords.toScreenSpace({ x: table.x, y: table.y });
return (
<g transform={`translate(${screenPos.x}, ${screenPos.y})`}>
<rect width={table.width} height={table.height} />
{/* table content */}
</g>
);
}
Relationship lines can temporarily change the cursor:
import { useCanvas } from '../hooks';
export default function Relationship({ rel }) {
const { pointer } = useCanvas();
return (
<path
d={rel.path}
onMouseEnter={() => pointer.setStyle('crosshair')}
onMouseLeave={() => pointer.setStyle('default')}
style={{ cursor: pointer.style }}
/>
);
}
Why This Architecture Works for ERD Editing
DrawDB's canvas implementation prioritizes predictability and extension over feature density. The key design decisions are:
- SVG-native rendering – Vector graphics stay sharp at any zoom, essential for diagram export quality
- Coordinate isolation – Model coordinates (where tables "live") never mix with screen coordinates (where they're drawn)
- Context colocation – All canvas-related state lives in one provider, making debugging and testing straightforward
- Hook-based consumption – Components declare their canvas dependencies explicitly through
useCanvas
According to the drawdb-io/drawdb source code, this architecture supports real-time collaboration features and 50,000+ element diagrams without frame drops on modern hardware.
Summary
- CanvasContext (
src/context/CanvasContext.jsx) defines the shape of canvas state including size, viewBox, coordinate converters, and pointer tracking - CanvasContextProvider computes the SVG
viewBox, manages resize observation, and suppliestoDiagramSpace/toScreenSpaceconversion helpers - useTransform hook (
src/hooks/useTransform.js) provides pan and zoom state consumed by the provider - useCanvas hook (
src/hooks/useCanvas.js) offers convenient access to canvas context for all child components - Workspace.jsx integrates the provider at the top level, enabling diagram elements to render with correct positioning and interactivity
Frequently Asked Questions
What makes DrawDB's canvas resolution-agnostic?
The SVG viewBox computed in CanvasContextProvider scales all diagram elements proportionally. Model coordinates remain fixed while the viewBox adapts to container size and zoom level, ensuring tables and relationships appear identical at 100% zoom on a laptop or 200% zoom on a 4K monitor.
How does DrawDB convert between screen and model coordinates?
The coords object from useCanvas provides two methods: toDiagramSpace divides screen pixels by zoom and accounts for pan offset to return model coordinates; toScreenSpace multiplies model coordinates by zoom and adds the centering offset. Both are memoized in CanvasContextProvider to prevent unnecessary recalculations during renders.
Can I use DrawDB's canvas system for non-ERD diagrams?
Yes. The canvas context is generic—it knows nothing about tables or relationships. Any React component tree wrapped in CanvasContextProvider gains pan, zoom, resize handling, and coordinate conversion. The ERD-specific logic lives in components like TableNode and Relationship, not in the canvas infrastructure itself.
Where is pointer state used besides cursor styling?
Pointer position from useCanvas enables features like drag-to-pan, marquee selection, and connection drawing. The coordinates are in screen space (pointer.x, pointer.y) but convert to model space via coords.toDiagramSpace for operations that affect diagram data, such as dropping a new table or routing a relationship line.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →