canvu-react
v0.4.59
Published
Vector-first infinite canvas (SVG) with pan, zoom, React bindings, and optional plugins
Readme
canvu
canvu is a vector-first SVG canvas for the browser with pan, zoom, tools, persistence, plugins, and realtime collaboration.
Install
npm install canvu react react-dom lucide-reactRequired peers:
reactreact-domlucide-react
Optional peers:
@ai-sdk/reactai
Entry points
| Import | Purpose |
| --- | --- |
| canvu | Core primitives: camera, scene, SVG renderer, input helpers, shape builders |
| canvu/react | React runtime: VectorViewport, VectorToolbar, persistence, plugin system |
| canvu/plugins/chatbot | Plug and play chatbot plugin |
| canvu/plugins/realtime | Plug and play realtime collaboration plugin + advanced hooks |
| canvu/plugins/tldraw | tldraw import helpers |
Quick start
import { useState } from "react";
import {
useVectorCanvasDocument,
VectorCanvas,
VectorToolbar,
VectorViewport,
} from "canvu/react";
export function Board() {
const [toolId, setToolId] = useState("hand");
const [toolLocked, setToolLocked] = useState(false);
const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });
if (!doc.isHydrated) return null;
return (
<VectorCanvas.Root style={{ height: "100dvh", width: "100%" }}>
<VectorCanvas.Body>
<VectorCanvas.Main>
<VectorCanvas.ViewportSurface>
<VectorViewport
ariaLabel="Board"
toolId={toolId}
toolLocked={toolLocked}
onToolChangeRequest={setToolId}
items={doc.items}
onItemsChange={doc.onItemsChange}
interactive
plugins={[]}
toolbar={
<VectorCanvas.Toolbar>
<VectorToolbar
value={toolId}
onChange={setToolId}
toolLocked={toolLocked}
onToolLockedChange={setToolLocked}
aria-label="Canvas tools"
/>
</VectorCanvas.Toolbar>
}
/>
</VectorCanvas.ViewportSurface>
</VectorCanvas.Main>
</VectorCanvas.Body>
</VectorCanvas.Root>
);
}Plugin-first architecture
The recommended React DX is plugin-first:
import { chatbotPlugin } from "canvu/plugins/chatbot";
import { realtimeCollaborationPlugin } from "canvu/plugins/realtime";
<VectorViewport
ariaLabel="Board"
items={doc.items}
onItemsChange={doc.onItemsChange}
toolId={toolId}
onToolChangeRequest={setToolId}
interactive
plugins={[
chatbotPlugin({ chatApi: "/api/chat" }),
realtimeCollaborationPlugin({
url,
roomId,
peer: {
id: peerId,
displayName: "Kalmon",
color: "#7c3aed",
image: avatarUrl,
},
}),
]}
/>No CanvasPluginHost is required. The plugin runtime lives inside VectorViewport.
Persisting built-in file uploads
If you want to keep the native VectorViewport file picker and drag-and-drop
UX while also saving the original binary to your own backend or object storage,
pass assetStore.
import { useState } from "react";
import {
type VectorViewportAssetStore,
useVectorCanvasDocument,
VectorCanvas,
VectorToolbar,
VectorViewport,
} from "canvu/react";
export function Board() {
const [toolId, setToolId] = useState("hand");
const [toolLocked, setToolLocked] = useState(false);
const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });
const assetStore: VectorViewportAssetStore = {
async upload({ file, kind }) {
const form = new FormData();
form.append("file", file);
form.append("kind", kind);
const response = await fetch("/api/canvu/assets", {
method: "POST",
body: form,
});
const asset = await response.json();
return {
pluginData: {
assetId: asset.id,
assetKey: asset.key,
mimeType: file.type,
originalFileName: file.name,
},
};
},
async resolve({ assetIds }) {
const response = await fetch("/api/canvu/assets/resolve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ assetIds }),
});
return response.json();
},
};
if (!doc.isHydrated) return null;
return (
<VectorCanvas.Root style={{ height: "100dvh", width: "100%" }}>
<VectorCanvas.Body>
<VectorCanvas.Main>
<VectorCanvas.ViewportSurface>
<VectorViewport
ariaLabel="Board"
toolId={toolId}
toolLocked={toolLocked}
onToolChangeRequest={setToolId}
items={doc.items}
onItemsChange={doc.onItemsChange}
interactive
assetStore={assetStore}
toolbar={
<VectorCanvas.Toolbar>
<VectorToolbar
value={toolId}
onChange={setToolId}
toolLocked={toolLocked}
onToolLockedChange={setToolLocked}
aria-label="Canvas tools"
/>
</VectorCanvas.Toolbar>
}
/>
</VectorCanvas.ViewportSurface>
</VectorCanvas.Main>
</VectorCanvas.Body>
</VectorCanvas.Root>
);
}canvu still owns the conversion from File to VectorSceneItem. Your
assetStore.upload(...) only intercepts the original browser file and returns
persistable pluginData, which canvu merges into every created item from that
file. resolve(...) is optional and is intended for custom persistence
adapters that need to rehydrate signed URLs after loading a snapshot.
Reusing the same ingestion pipeline outside the viewport
When files originate outside the VectorViewport UI, such as a board creation
wizard, a custom import modal, or a migration routine, use
ingestAssetFilesToSceneItems(...).
import { useRef, useState } from "react";
import {
ingestAssetFilesToSceneItems,
type VectorViewportAssetStore,
useVectorCanvasDocument,
VectorViewport,
} from "canvu/react";
export function BoardImport() {
const [toolId, setToolId] = useState("hand");
const inputRef = useRef<HTMLInputElement>(null);
const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });
const assetStore: VectorViewportAssetStore = {
async upload({ file, kind }) {
const form = new FormData();
form.append("file", file);
form.append("kind", kind);
const response = await fetch("/api/canvu/assets", {
method: "POST",
body: form,
});
const asset = await response.json();
return {
pluginData: {
assetId: asset.id,
assetKey: asset.key,
},
};
},
};
async function handleImport(files: FileList | null) {
if (!files) return;
const result = await ingestAssetFilesToSceneItems({
files: Array.from(files),
worldCenter: { x: 0, y: 0 },
assetStore,
pdfScale: 1.15,
pdfPageConcurrency: 2,
onItemsReady(nextItems) {
doc.onItemsChange([...doc.items, ...nextItems]);
},
decorateItem(item) {
return {
...item,
locked: true,
};
},
});
doc.onItemsChange([...doc.items, ...result.items]);
}
if (!doc.isHydrated) return null;
return (
<>
<button type="button" onClick={() => inputRef.current?.click()}>
Import files
</button>
<input
ref={inputRef}
type="file"
accept="image/*,application/pdf"
multiple
hidden
onChange={(event) => {
void handleImport(event.target.files);
event.target.value = "";
}}
/>
<VectorViewport
ariaLabel="Board"
items={doc.items}
onItemsChange={doc.onItemsChange}
toolId={toolId}
onToolChangeRequest={setToolId}
interactive
assetStore={assetStore}
/>
</>
);
}canvu can now stream PDF pages into the canvas progressively through
onItemsReady(...), skip PDF thumbnails during ingest, and use a lower initial
raster scale by default to improve time-to-first-render.
The native file tool now accepts multiple images and PDFs from the picker and stacks every imported asset vertically. When the board already has uploaded images or PDFs, new imports are inserted immediately after the existing stack instead of overlapping previous content.
This helper is the same ingestion layer used internally by the native file
tool, so external imports do not need to reimplement PDF rasterization, local
blob persistence, or pluginData attachment.
Custom tools
Use createToolPlugin(...) for isolated tools.
import { createToolPlugin } from "canvu/react";
import { Pin } from "lucide-react";
const reviewPinPlugin = createToolPlugin({
id: "review-pin",
label: "Review",
shortcutHint: "R",
icon: <Pin aria-hidden />,
createItem: ({ id, bounds }) => createReviewPinItem(id, bounds),
});
<VectorViewport plugins={[reviewPinPlugin]} ... />If the tool belongs to a larger feature, keep it inside that feature plugin.
Advanced hooks
Low-level hooks remain public for advanced customization:
useRealtimeSession(...)useRealtimeComments(...)useCanvuPluginContext()useCanvuViewportContext()useCanvuDocumentContext()useCanvuPluginContribution(...)createCanvuPlugin(...)
Use them for custom plugins and bespoke UIs, not for the common path.
CSS requirement
The interactive surface must use touch-action: none so the browser does not steal gestures.
Security
childrenSvg is injected via innerHTML.
Only pass trusted or sanitized SVG.
