@ajosecortes/vastly
v0.1.1
Published
Framework-agnostic infinite canvas engine — build Figma-like editors, whiteboards, and diagram tools
Maintainers
Readme
Vastly — Infinite Canvas Engine
Framework-agnostic TypeScript library for building infinite 2D canvases. Think Figma, Miro, or tldraw — but you control what every element looks like and how it behaves.
The engine only understands geometry (position, size, rotation). All business semantics — what a "sticky note" or "image" or "chart" means — lives in your application layer.
pnpm add @ajosecortes/core @ajosecortes/vastly-reactArchitecture
┌─────────────────────────────────────┐
│ Adapters (React / Vue / Vanilla) │ ← real rendering of elements
├─────────────────────────────────────┤
│ Interaction Layer │ ← selection, drag, snapping
├─────────────────────────────────────┤
│ Scene (elements) │ ← geometry, quadtree spatial index
├─────────────────────────────────────┤
│ Camera / Viewport │ ← zoom, pan, coordinate transforms
├─────────────────────────────────────┤
│ Store (state + history) │ ← undo/redo, subscriptions, persistence
└─────────────────────────────────────┘Dependency rule: each layer only depends on the ones below it. The Store doesn't know React exists. The Scene doesn't know what a "drag" is.
Quick Start
import { CanvasProvider, useCanvas, useCamera, CanvasElementView } from '@ajosecortes/vastly-react';
function App() {
return (
<CanvasProvider>
<Toolbar />
<Canvas />
</CanvasProvider>
);
}
function Canvas() {
const { elements, addElement } = useCanvas();
const camera = useCamera();
return (
<div
onWheel={(e) => {
e.deltaY < 0 ? camera.zoomIn() : camera.zoomOut();
}}
style={{ overflow: 'hidden', width: '100%', height: '100%' }}
>
<div
style={{
transform: `translate(${-camera.x * camera.zoom}px, ${-camera.y * camera.zoom}px) scale(${camera.zoom})`,
}}
>
{elements.map((el) => (
<CanvasElementView key={el.id} element={el}>
{el.render()}
</CanvasElementView>
))}
</div>
</div>
);
}See examples/basic/ for a full working demo with sticky notes, drag, pan, zoom, undo/redo, and double-click to edit.
Core API (@ajosecortes/core)
CanvasElement
interface CanvasElement {
id: string;
x: number;
y: number;
width: number;
height: number;
rotation?: number; // radians, default 0
zIndex?: number;
locked?: boolean; // blocks drag/resize/delete
visible?: boolean; // default true
render: () => unknown; // opaque — the engine never calls this
}Store
const store = createStore();
// CRUD
const id = store.addElement({ x: 0, y: 0, width: 100, height: 100, render: myRenderer });
store.updateElement(id, { x: 50 });
store.removeElement(id);
// History
store.undo();
store.redo();
store.canUndo(); // boolean
store.canRedo(); // boolean
// Batch multiple mutations as a single undo step
store.batch(() => {
store.addElement(note1);
store.addElement(note2);
});
// Persistence
const snapshot = store.serialize(); // CanvasSnapshot
store.load(snapshot);
// Reactivity
const unsub = store.subscribe((event) => {
// event: CanvasChangeEvent
});Camera
const camera = createCamera();
// Pan
camera.panBy(10, 20);
camera.panTo(500, 500);
// Zoom (clamped [0.1, 50])
camera.zoomTo(2, { x: 200, y: 200 }); // zoom around screen point
camera.zoomIn();
camera.zoomOut();
// Coordinate conversion
const canvasPoint = camera.screenToCanvas({ x: 300, y: 200 });
const screenPoint = camera.canvasToScreen({ x: 500, y: 300 });
// Fit to content
camera.fitToContent(elements, padding);
camera.centerOn(elementId, elements);
// Read position
camera.x; // current pan X
camera.y; // current pan Y
camera.zoom; // current zoom levelScene (Quadtree)
const scene = createScene();
scene.addElement(el);
scene.updateElement(id, el); // re-inserts in quadtree on move
scene.removeElement(id);
// Range query — O(log n) average
const ids = scene.queryRange({ x: 0, y: 0, width: 800, height: 600 });Interaction
// Selection
const selection = createSelectionManager(onChange);
selection.select(['a', 'b'], 'replace'); // 'replace' | 'add' | 'toggle'
selection.clear();
selection.getBoundingBox(elements); // Rect | null
// Drag
const drag = createDragController();
drag.onDragStart((ids, event) => { /* ... */ });
drag.onDrag((delta, ids) => { /* ... */ });
drag.onDragEnd((ids) => { /* ... */ });
// Snap (optional, disableable)
const snap = createSnapEngine(threshold);
snap.enabled = false;
const { guides, adjustedPosition } = snap.computeSnap(moving, others);Virtualizer
const virtualizer = createVirtualizer(scene, camera, bufferMargin);
const visibleIds = virtualizer.getVisibleElementIds();
virtualizer.setBufferMargin(400); // px outside viewport to still renderReact Adapter (@ajosecortes/vastly-react)
| Export | Purpose |
|--------|---------|
| <CanvasProvider> | Initializes Store + Camera + Scene + Virtualizer. Wrap your app. |
| useCanvas() | Returns { elements, visibleElements, addElement, updateElement, removeElement, undo, redo, canUndo, canRedo } |
| useCamera() | Returns the Camera instance |
| <CanvasElementView> | Wraps an element with CSS transform: translate(x, y) rotate(r) |
Provider
<CanvasProvider initialSnapshot={snapshot /* optional */}>
<YourApp />
</CanvasProvider>Hooks
function MyComponent() {
const { elements, visibleElements, addElement, undo, redo, canUndo } = useCanvas();
const camera = useCamera();
// elements: all elements
// visibleElements: only those in viewport + buffer
}Element View
<CanvasElementView element={el}>
<YourCustomContent />
</CanvasElementView>Applies position: absolute, transform: translate(x, y) rotate(rotation), zIndex, and visibility automatically.
Performance
| Target | Status |
|--------|--------|
| 10,000 elements at 60 FPS during pan | ✅ Designed for (quadtree + virtualizer) |
| Virtualized rendering | ✅ Only viewport + buffer elements call render() |
| requestAnimationFrame batching | ✅ Camera pan/zoom coalesced per frame |
| Benchmark in CI | ✅ Playwright harness in packages/benchmarks/ |
Run benchmarks:
pnpm benchProject Structure
packages/
├── core/ # @ajosecortes/core — framework-agnostic engine
│ └── src/
│ ├── types.ts
│ ├── store.ts
│ ├── camera.ts
│ ├── scene.ts # quadtree + scene
│ ├── interaction.ts # selection, drag, snap
│ ├── virtualizer.ts
│ └── index.ts
├── react/ # @ajosecortes/vastly-react — React adapter
│ └── src/
│ ├── CanvasProvider.tsx
│ └── index.ts
├── benchmarks/ # Playwright performance tests
└── examples/
└── basic/ # Full working demo (Vite + React)Development
pnpm install
pnpm test # 48 tests across all packages
pnpm typecheck # TypeScript in all packages
pnpm build # Build all packages
# Run the example
cd examples/basic
pnpm devRoadmap
| Version | Scope | |---------|-------| | v0.1 ✅ | Store, Camera, Scene (quadtree), Interaction, Virtualizer, React adapter | | v0.2 | Box selection, animated pan, touch gestures | | v0.3 | Vue adapter, benchmark CI integration | | v1.0 | Stable API, public docs site | | Post-v1 | Real-time collaboration (CRDT), export to image/PDF |
License
MIT
