@mhamz.01/easyflow-whiteboard
v2.181.0
Published
A feature-rich whiteboard component built with Fabric.js and React
Maintainers
Readme
@mhamz.01/easyflow-whiteboard
A feature-rich, embeddable React whiteboard built on Fabric.js, Zustand, and TailwindCSS. Drop it into any React 18+ app and get a fully functional infinite canvas with drawing tools, HTML overlay nodes, and automatic persistence.
Table of Contents
- Installation
- Quick Start
- Component API
- Types
- Store API
- Available Tools
- Keyboard Shortcuts
- Persistence Pattern
- Architecture Overview
- Directory Structure
- Performance Design
- Built With
Installation
npm install @mhamz.01/easyflow-whiteboardPeer Dependencies
npm install fabric@^7 react@^18 react-dom@^18 zustand@^5 \
@radix-ui/react-dropdown-menu@^2 \
@radix-ui/react-label@^2 \
@radix-ui/react-slider@^1Quick Start
import { EasyflowWhiteboard } from '@mhamz.01/easyflow-whiteboard';
import '@mhamz.01/easyflow-whiteboard/dist/styles.css';
export default function Page() {
return (
<div style={{ width: '100%', height: '100vh' }}>
<EasyflowWhiteboard />
</div>
);
}Next.js note: The component uses
"use client"internally. Wrap it in a dynamic import or a client boundary if you use the App Router.
Component API
<EasyflowWhiteboard />
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| initialData | WhiteboardInitialData | — | Pre-load saved canvas JSON and HTML nodes |
| onSave | (payload: WhiteboardSavePayload) => void | — | Debounced callback fired whenever the canvas changes |
| saveDebounceMs | number | 2000 | Debounce delay (ms) for onSave |
| availableTasks | TaskTemplate[] | [] | Tasks shown in the task picker dropdown |
| availableDocuments | DocumentTemplate[] | [] | Documents shown in the document picker dropdown |
| isLoadingData | boolean | false | Show toolbar skeleton while async data loads |
| createNewTaskHref | string | — | URL for the "Create new task" footer link |
| createNewDocumentHref | string | — | URL for the "Create new document" footer link |
| editable | boolean | true | Set false to make the canvas fully read-only. See EDITABLE_MODE.md |
Types
WhiteboardInitialData
Shape of the data you pass in to restore a saved board.
interface WhiteboardInitialData {
canvas?: string; // canvas.toJSON() string from a previous save
tasks?: TaskNodeData[];
documents?: DocumentNodeData[];
}WhiteboardSavePayload
Shape of the data the onSave callback receives.
interface WhiteboardSavePayload {
canvas: string; // Serialized Fabric.js canvas state
tasks: TaskNodeData[];
documents: DocumentNodeData[];
}TaskTemplate / TaskNodeData
interface TaskNodeData {
id: string;
type: "task";
title: string;
status: "todo" | "in-progress" | "done";
x: number; // World-space X position on the canvas
y: number; // World-space Y position on the canvas
assignee?: string;
project?: string;
priority?: "low" | "medium" | "high";
dueDate?: string;
}DocumentTemplate / DocumentNodeData
interface DocumentNodeData {
id: string;
type: "document";
title: string;
project: string;
breadcrumb?: string[];
preview: string;
updatedAt?: string;
x: number;
y: number;
}Store API
Access and mutate whiteboard state from anywhere in your app:
import { useWhiteboardStore } from '@mhamz.01/easyflow-whiteboard';
function MyComponent() {
const activeTool = useWhiteboardStore((s) => s.activeTool);
const setActiveTool = useWhiteboardStore((s) => s.setActiveTool);
return <button onClick={() => setActiveTool("pen")}>Pen</button>;
}State Slices
| Field | Type | Description |
|-------|------|-------------|
| activeTool | Tool | Currently selected drawing tool |
| setActiveTool | (tool: Tool) => void | Switch the active tool programmatically |
| toolOptions | ToolOptions | Per-tool styling options (color, strokeWidth, etc.) |
| setToolOption | (tool, option, value) => void | Update a single tool option |
| selectedObjects | FabricObject[] | Fabric objects currently selected on canvas |
| canUndo / canRedo | boolean | Whether undo/redo is available |
| zoom | number | Current canvas zoom level (1 = 100%) |
| bringToFront | (canvas) => void | Move selected object to the top layer |
| sendToBack | (canvas) => void | Move selected object to the bottom layer |
Available Tools
| Tool ID | Shortcut | Description |
|---------|----------|-------------|
| select | V | Select and move Fabric objects |
| pan | H | Click-drag to pan the viewport; two-finger pinch to zoom |
| pen | P | Freehand drawing with configurable stroke |
| rectangle | R | Draw rectangles (drag to size) |
| circle | C | Draw circles (drag to size) |
| frame | F | Draw white frames — always rendered behind other objects |
| line | L | Draw straight lines |
| arrow | A | Draw lines with a filled arrowhead |
| text | T | Place editable inline text |
| image | I | Upload and place an image from disk |
| eraser | E | Brush-erase objects by painting over them |
| undo | Ctrl+Z | Undo the last canvas change |
| redo | Ctrl+Shift+Z | Redo the last undone change |
Keyboard Shortcuts
| Key | Action |
|-----|--------|
| V H P R C F L A T I E | Activate corresponding tool |
| Delete / Backspace | Delete selected objects or HTML nodes |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z or Ctrl+Y | Redo |
| Ctrl+C | Copy selected Fabric objects |
| Ctrl+V | Paste with cascading offset |
| Ctrl+A | Select all HTML nodes (tasks + documents) |
| Escape | Clear HTML node selection |
| Ctrl++ / Ctrl+- | Zoom in / out |
| Ctrl+0 | Reset zoom to 100% |
| Ctrl+Scroll | Zoom to cursor |
| Shift+Scroll | Horizontal pan |
Persistence Pattern
The library owns the UI and interaction. You own the data storage:
import { EasyflowWhiteboard, WhiteboardInitialData, WhiteboardSavePayload } from '@mhamz.01/easyflow-whiteboard';
import '@mhamz.01/easyflow-whiteboard/dist/styles.css';
import { useCallback, useEffect, useState } from 'react';
export default function BoardPage({ boardId }: { boardId: string }) {
const [initialData, setInitialData] = useState<WhiteboardInitialData>();
const [isLoading, setIsLoading] = useState(true);
// 1. Load saved state from your database on mount
useEffect(() => {
fetch(`/api/boards/${boardId}`)
.then(r => r.json())
.then(data => {
setInitialData({
canvas: data.canvasJson,
tasks: data.tasks,
documents: data.documents,
});
setIsLoading(false);
});
}, [boardId]);
// 2. Save on every debounced change
const handleSave = useCallback(async (payload: WhiteboardSavePayload) => {
await fetch(`/api/boards/${boardId}`, {
method: 'PUT',
body: JSON.stringify({
canvasJson: payload.canvas,
tasks: payload.tasks,
documents: payload.documents,
}),
});
}, [boardId]);
return (
<div style={{ width: '100%', height: '100vh' }}>
<EasyflowWhiteboard
initialData={initialData}
onSave={handleSave}
saveDebounceMs={2000}
isLoadingData={isLoading}
availableTasks={myProjectTasks}
availableDocuments={myProjectDocs}
createNewTaskHref="/tasks/new"
createNewDocumentHref="/docs/new"
/>
</div>
);
}Architecture Overview
EasyflowWhiteboard (whiteboard-test.tsx)
│
├── <canvas> Fabric.js v7 rendering surface
│ Handles: shapes, paths, images, arrows, frames, text, selection
│
├── CanvasOverlayLayer Absolutely-positioned HTML div
│ ├── TaskNode Rich task card (status, assignee, priority)
│ └── DocumentNode Rich document card (preview, breadcrumb)
│ Reads canvas viewport transform on every `after:render` to stay aligned
│
├── WhiteboardToolbar Fixed bottom bar
│ ├── Tool buttons (V H P R C F L A T I E)
│ ├── TaskDropdown Template picker → add task to canvas
│ └── DocumentDropdown Template picker → add doc to canvas
│
├── ToolOptionsPanel Left sidebar (appears for active tools)
│ └── PenOptions / ShapeOptions / TextOptions / ImageOptions / ...
│
└── ZoomControls Top-right zoom percentage pillTwo-layer rendering
Fabric.js renders vector objects (shapes, paths, images) on a <canvas> element. An HTML <div> overlay, transformed with the same viewportTransform matrix, renders interactive React cards (tasks, documents). The two layers share a single world coordinate system — placing a node at (x: 100, y: 200) positions it exactly over canvas coordinates (100, 200).
Hook composition
The root FabricWhiteboard component delegates every behaviour to a dedicated hook:
| Hook | Responsibility |
|------|---------------|
| useCanvasInit | Mount canvas, hydrate saved state, handle resize |
| useToolManager | Switch drawing mode and cursors on tool change |
| useDrawing | Mouse-drag shape creation |
| useEraser | Brush cursor, trail render, object deletion |
| useMouseHandlers | Single event router with rAF throttle |
| usePan | Viewport drag and pinch zoom |
| useZoom | Wheel zoom, keyboard shortcuts |
| useSelection | Fabric selection → store, rubber-band box |
| useTextStyle | Live font/color updates on selected text |
| useLiveUpdate | Live style updates on selected objects |
| usePersistence | Debounced auto-save + history push |
| useCopyPaste | Ctrl+C / Ctrl+V clipboard |
Directory Structure
src/
├── index.ts Public API — component, store, and types
├── styles.css Component stylesheet (TailwindCSS, scoped)
│
├── store/
│ └── whiteboard-store.ts Zustand store (tools, history, zoom, layer order)
│
├── types/
│ └── canvas-node.ts TaskNodeData, DocumentNodeData, CanvasNode
│
├── lib/
│ ├── fabric-utils.ts Canvas setup helpers, shape updates, welcome animation
│ ├── fabric-arrow.ts Custom Arrow shape (Line + triangle arrowhead)
│ ├── fabric-bidirectional-arrow.ts Custom BidirectionalArrow shape (arrowheads both ends)
│ ├── fabric-frame.ts Custom Frame shape (white Rect, always behind)
│ └── utils.ts cn() — clsx + tailwind-merge
│
├── hooks/ Canvas behaviour hooks (consumed by whiteboard-test.tsx)
│ ├── useCanvasInit.ts Fabric canvas mount, hydration, resize
│ ├── useToolManager.ts Drawing mode + cursor switch on tool change
│ ├── useDrawing.ts Mouse-drag shape creation (rect, circle, etc.)
│ ├── useEraser.ts Eraser brush, trail, object deletion
│ ├── useMouseHandlers.ts Single event router with rAF throttle on mouse:move
│ ├── usePan.ts Viewport drag and two-finger pinch zoom
│ ├── useZoom.ts Wheel zoom, toolbar buttons, keyboard shortcuts
│ ├── useSelection.ts Fabric selection events → store + rubber-band box
│ ├── useTextStyle.ts Live font/size/color sync to selected IText
│ ├── useLiveUpdate.ts Live fill/stroke sync to selected objects
│ ├── usePersistance.ts Debounced onSave callback + undo history push
│ └── useCopyPaste.ts Ctrl+C / Ctrl+V with cascading paste offset
│
└── components/
├── whiteboard/
│ └── whiteboard-test.tsx Root component — composes all hooks and layers
│
├── node/ HTML overlay: draggable cards synced to canvas
│ ├── custom-node-overlay-layer.tsx Overlay orchestrator (renders all nodes)
│ ├── custom-node.tsx TaskNode — task card with status toggle
│ ├── document-node.tsx DocumentNode — document card with preview
│ ├── types/
│ │ └── overlay-types.ts Shared interfaces (Task, Document, DragState, etc.)
│ └── hooks/
│ ├── useNodeState.ts Local state + parent prop sync
│ ├── useNodeSelection.ts selectedIds set + selection guard refs
│ ├── useNodeDrag.ts Full drag lifecycle with rAF throttle
│ ├── useFabricSync.ts Fabric object:moving → HTML position delta
│ ├── useWheelZoom.ts Forwards wheel events from overlay to Fabric
│ ├── useSelectionBox.ts Rubber-band hit-test for HTML nodes
│ └── useKeyboardShortcuts.ts Ctrl+A, Escape, Delete for overlay nodes
│
├── toolbar/
│ ├── whiteboard-toolbar.tsx Bottom tool bar — buttons, shortcuts, dropdowns
│ ├── tooloptions-panel.tsx Left sidebar — properties for active tool or selection
│ ├── toolbar-button.tsx Reusable icon button with tooltip
│ ├── toolbar-seperator.tsx Vertical divider between toolbar groups
│ ├── task-dropdown.tsx Task template picker (opens above toolbar)
│ ├── document-dropdown.tsx Document template picker (opens above toolbar)
│ ├── layers-control.tsx Bring/send layer order controls
│ ├── options/ Per-tool options panels
│ │ ├── pen-option.tsx Stroke color, width, dash style
│ │ ├── shape-option.tsx Fill, stroke, dash style for rect/circle/frame
│ │ ├── text-option.tsx Font family, size, weight, alignment, color
│ │ ├── image-options.tsx Opacity and filter controls for images
│ │ ├── line-options.tsx Stroke color, width, dash style for lines
│ │ └── arrow-options.tsx Stroke color, width for arrows
│ └── toolbar-skeleton/
│ └── toolbar-skeleton.tsx Animated loading skeleton matching toolbar shape
│
├── ui/ Low-level Radix UI wrappers (no business logic)
│ ├── dropdown-menu.tsx Radix DropdownMenu
│ ├── label.tsx Radix Label
│ └── slider.tsx Radix Slider
│
└── zoomcontrol/
└── zoom-control.tsx Zoom percentage pill with +/− buttonsPerformance Design
The library is built to stay smooth at 60fps even during fast draws and large canvases.
| Pattern | Where | Why |
|---------|-------|-----|
| requestRenderAll() everywhere | All hooks | Defers paint to Fabric's rAF loop; never blocks the main thread |
| rAF throttle on mouse:move | useMouseHandlers | Raw pointer rate can be 200Hz; throttle to one frame |
| Props in refs, effects register once | All canvas hooks | Avoids tearing down/reattaching listeners on every render |
| React.memo + custom comparators | Toolbar, Overlay wrappers | Pan/zoom state changes don't re-render toolbar or node cards |
| Delta accumulator in useFabricSync | Node drag | Never drops position updates even when RAF guard is active |
| requestIdleCallback for JSON serialization | usePersistence | Expensive canvas.toJSON() runs during browser idle time |
| History capped at 50 snapshots | whiteboard-store | Each snapshot can be 100 KB+; cap prevents memory growth |
Built With
| Dependency | Role | |-----------|------| | Fabric.js v7 | Canvas rendering engine | | Zustand v5 | Lightweight state management | | Radix UI | Accessible headless UI primitives | | Lucide React | Icon set | | TailwindCSS v4 | Utility-first styling | | clsx + tailwind-merge | Safe class name composition |
License
MIT © Muhammad Hamza
