feat: RefBoard v0.4.0 — collaborative reference board with layers, groups & polished UI

Full-featured PureRef-style collaborative canvas for game dev teams:
- Layer panel with visibility, lock, drag reorder, group/ungroup (Ctrl+G/Shift+G)
- Arrangement tools (grid, row, column) via right-click context menu
- Copy to system clipboard (Ctrl+C writes PNG for external paste in Paint etc.)
- Number shortcuts (1-5) for tool selection with visible shortcut badges
- Premium dark UI across all pages (Login, Collections, Boards, Editor)
- Socket.IO rooms for cursors, transforms, and presence notifications
- MinIO image storage with backend proxy, drag/drop and paste upload
This commit is contained in:
Hiren
2026-03-09 13:57:49 +05:30
commit e47777d237
43 changed files with 10276 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
import React, { useRef, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react';
import { Canvas, FabricImage, FabricObject } from 'fabric';
import { suppressBroadcasts, resumeBroadcasts } from './sync';
export interface FabricCanvasHandle {
getCanvas: () => Canvas | null;
fitAll: () => void;
getZoom: () => number;
setZoom: (zoom: number) => void;
}
interface FabricCanvasProps {
canvasState?: string | null;
currentTool: string;
onChange?: () => void;
}
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 5.0;
const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
({ canvasState, currentTool, onChange }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasElRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<Canvas | null>(null);
const isPanning = useRef(false);
const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
const spaceHeld = useRef(false);
const initialLoadDone = useRef(false);
// Initialize Fabric canvas
useEffect(() => {
if (!canvasElRef.current || fabricRef.current) return;
const container = containerRef.current!;
const width = container.clientWidth;
const height = container.clientHeight;
const canvas = new Canvas(canvasElRef.current, {
width,
height,
backgroundColor: '#1e1e1e',
selection: true,
selectionColor: 'rgba(74, 158, 255, 0.15)',
selectionBorderColor: '#4a9eff',
selectionLineWidth: 1,
preserveObjectStacking: true,
});
fabricRef.current = canvas;
// Handle resize
const observer = new ResizeObserver(() => {
const w = container.clientWidth;
const h = container.clientHeight;
canvas.setDimensions({ width: w, height: h });
canvas.requestRenderAll();
});
observer.observe(container);
// Middle-mouse pan
canvas.on('mouse:down', (e: any) => {
if (e.e.button === 1 || (spaceHeld.current && e.e.button === 0)) {
isPanning.current = true;
lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY };
canvas.defaultCursor = 'grabbing';
canvas.upperCanvasEl.style.cursor = 'grabbing';
e.e.preventDefault();
}
});
canvas.on('mouse:move', (e: any) => {
if (!isPanning.current || !lastPanPoint.current) return;
const vpt = canvas.viewportTransform!;
vpt[4] += e.e.clientX - lastPanPoint.current.x;
vpt[5] += e.e.clientY - lastPanPoint.current.y;
lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY };
canvas.requestRenderAll();
});
canvas.on('mouse:up', () => {
if (isPanning.current) {
isPanning.current = false;
lastPanPoint.current = null;
if (!spaceHeld.current) {
canvas.defaultCursor = 'default';
canvas.upperCanvasEl.style.cursor = 'default';
}
}
});
// Ctrl+Scroll zoom
canvas.on('mouse:wheel', (opt: any) => {
const e = opt.e as WheelEvent;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY;
let zoom = canvas.getZoom();
zoom *= 0.999 ** delta;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
const point = canvas.getScenePoint(e);
canvas.zoomToPoint(point, zoom);
canvas.requestRenderAll();
});
// Space key for pan
function onKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !spaceHeld.current && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) {
e.preventDefault();
spaceHeld.current = true;
canvas.defaultCursor = 'grab';
canvas.upperCanvasEl.style.cursor = 'grab';
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
spaceHeld.current = false;
if (currentTool !== 'PAN') {
canvas.defaultCursor = 'default';
canvas.upperCanvasEl.style.cursor = 'default';
}
}
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
// Notify parent of changes
const changeEvents = ['object:added', 'object:modified', 'object:removed', 'path:created'];
const changeHandler = () => {
onChange?.();
};
changeEvents.forEach((evt) => canvas.on(evt as any, changeHandler));
return () => {
observer.disconnect();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
changeEvents.forEach((evt) => canvas.off(evt as any, changeHandler));
canvas.dispose();
fabricRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load initial canvas state
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas || initialLoadDone.current) return;
if (!canvasState) return;
initialLoadDone.current = true;
let parsed: any;
try {
parsed = typeof canvasState === 'string' ? JSON.parse(canvasState) : canvasState;
} catch {
return;
}
if (!parsed || (!parsed.objects && !parsed.version)) return;
// Suppress socket broadcasts during initial load — prevents flooding
// other clients with object:add events for objects they already have
suppressBroadcasts();
canvas.loadFromJSON(parsed).then(() => {
canvas.getObjects().forEach((obj: FabricObject) => {
if (obj.type === 'image') {
const imgObj = obj as FabricImage;
const src = (imgObj as any).src || imgObj.getSrc?.();
if (src) {
(imgObj as any).crossOrigin = 'anonymous';
}
}
});
canvas.requestRenderAll();
// Small delay to ensure all deferred events have fired before resuming
setTimeout(() => resumeBroadcasts(), 100);
}).catch((err: Error) => {
console.error('Failed to load canvas state:', err);
resumeBroadcasts();
});
}, [canvasState]);
const fitAll = useCallback(() => {
const canvas = fabricRef.current;
if (!canvas) return;
const objects = canvas.getObjects();
if (objects.length === 0) {
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
canvas.requestRenderAll();
return;
}
// Get bounding rect of all objects
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
objects.forEach((obj) => {
const bound = obj.getBoundingRect();
minX = Math.min(minX, bound.left);
minY = Math.min(minY, bound.top);
maxX = Math.max(maxX, bound.left + bound.width);
maxY = Math.max(maxY, bound.top + bound.height);
});
const objWidth = maxX - minX;
const objHeight = maxY - minY;
if (objWidth === 0 || objHeight === 0) return;
const padding = 40;
const canvasW = canvas.width!;
const canvasH = canvas.height!;
const scaleX = (canvasW - padding * 2) / objWidth;
const scaleY = (canvasH - padding * 2) / objHeight;
const zoom = Math.min(scaleX, scaleY, MAX_ZOOM);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
canvas.setViewportTransform([
zoom, 0, 0, zoom,
canvasW / 2 - cx * zoom,
canvasH / 2 - cy * zoom,
]);
canvas.requestRenderAll();
}, []);
useImperativeHandle(ref, () => ({
getCanvas: () => fabricRef.current,
fitAll,
getZoom: () => fabricRef.current?.getZoom() ?? 1,
setZoom: (zoom: number) => {
const canvas = fabricRef.current;
if (!canvas) return;
const center = canvas.getCenterPoint();
canvas.zoomToPoint(center, Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)));
canvas.requestRenderAll();
},
}), [fitAll]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
background: '#1e1e1e',
}}
>
<canvas ref={canvasElRef} />
</div>
);
}
);
FabricCanvas.displayName = 'FabricCanvas';
export default FabricCanvas;
+83
View File
@@ -0,0 +1,83 @@
import { Canvas } from 'fabric';
export class UndoManager {
private stack: string[] = [];
private pointer: number = -1;
private maxEntries: number = 50;
private locked: boolean = false;
private canvas: Canvas;
constructor(canvas: Canvas) {
this.canvas = canvas;
// Save initial state
this.saveState();
}
isLocked(): boolean {
return this.locked;
}
saveState(): void {
if (this.locked) return;
const json = JSON.stringify((this.canvas as any).toJSON(['id']));
// If we're not at the end, discard forward history
if (this.pointer < this.stack.length - 1) {
this.stack = this.stack.slice(0, this.pointer + 1);
}
// Don't save if identical to current state
if (this.stack.length > 0 && this.stack[this.pointer] === json) {
return;
}
this.stack.push(json);
// Enforce max entries
if (this.stack.length > this.maxEntries) {
this.stack.shift();
}
this.pointer = this.stack.length - 1;
}
undo(): void {
if (!this.canUndo()) return;
this.pointer--;
this.restoreState();
}
redo(): void {
if (!this.canRedo()) return;
this.pointer++;
this.restoreState();
}
canUndo(): boolean {
return this.pointer > 0;
}
canRedo(): boolean {
return this.pointer < this.stack.length - 1;
}
private restoreState(): void {
const state = this.stack[this.pointer];
if (!state) return;
this.locked = true;
this.canvas.loadFromJSON(JSON.parse(state)).then(() => {
this.canvas.requestRenderAll();
this.locked = false;
});
}
clear(): void {
this.stack = [];
this.pointer = -1;
this.saveState();
}
}
+207
View File
@@ -0,0 +1,207 @@
import { Canvas, FabricImage, Rect } from 'fabric';
import { uploadImage, uploadImageFromUrl } from '../api';
type OnImageAdded = () => void;
export function setupDragDrop(
canvas: Canvas,
boardId: string,
onImageAdded: OnImageAdded
): () => void {
const canvasEl = canvas.getSelectionElement();
const upperCanvas = canvas.upperCanvasEl || canvasEl;
const wrapper = upperCanvas?.parentElement || canvasEl.parentElement;
if (!wrapper) return () => {};
function onDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
}
async function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer?.files;
// Handle URL drops (dragged image URL from browser)
if (!files || files.length === 0) {
const url = e.dataTransfer?.getData('text/uri-list') || e.dataTransfer?.getData('text/plain') || '';
if (url && (url.startsWith('http://') || url.startsWith('https://')) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(url)) {
const rect = wrapper!.getBoundingClientRect();
const vpt = canvas.viewportTransform!;
const x = (e.clientX - rect.left - vpt[4]) / vpt[0];
const y = (e.clientY - rect.top - vpt[5]) / vpt[3];
const placeholder = new Rect({
left: x, top: y, width: 200, height: 150,
fill: '#3d3d3d', stroke: '#4a9eff', strokeWidth: 2,
strokeDashArray: [8, 4], selectable: false, evented: false,
});
canvas.add(placeholder);
canvas.requestRenderAll();
try {
const res = await uploadImageFromUrl(boardId, url);
const imgData = res.data.image || res.data;
const imgUrl = imgData.public_url;
canvas.remove(placeholder);
const imgEl = await FabricImage.fromURL(imgUrl, { crossOrigin: 'anonymous' });
imgEl.set({ left: x, top: y, id: imgData.id } as any);
const maxDim = 600;
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
imgEl.scale(scale);
}
canvas.add(imgEl);
canvas.requestRenderAll();
onImageAdded();
} catch (err) {
console.error('URL image upload failed:', err);
canvas.remove(placeholder);
canvas.requestRenderAll();
}
}
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
// Calculate drop position in canvas coordinates
const rect = wrapper!.getBoundingClientRect();
const vpt = canvas.viewportTransform!;
const x = (e.clientX - rect.left - vpt[4]) / vpt[0];
const y = (e.clientY - rect.top - vpt[5]) / vpt[3];
// Create placeholder
const placeholder = new Rect({
left: x,
top: y,
width: 200,
height: 150,
fill: '#3d3d3d',
stroke: '#4a9eff',
strokeWidth: 2,
strokeDashArray: [8, 4],
selectable: false,
evented: false,
});
canvas.add(placeholder);
canvas.requestRenderAll();
try {
const res = await uploadImage(boardId, file);
const imgData = res.data.image || res.data;
const url = imgData.public_url;
canvas.remove(placeholder);
const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' });
imgEl.set({
left: x,
top: y,
id: imgData.id,
} as any);
// Scale down large images
const maxDim = 600;
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
imgEl.scale(scale);
}
canvas.add(imgEl);
canvas.requestRenderAll();
onImageAdded();
} catch (err) {
console.error('Image upload failed:', err);
canvas.remove(placeholder);
canvas.requestRenderAll();
}
}
}
wrapper.addEventListener('dragover', onDragOver);
wrapper.addEventListener('drop', onDrop);
return () => {
wrapper.removeEventListener('dragover', onDragOver);
wrapper.removeEventListener('drop', onDrop);
};
}
export function setupPaste(
canvas: Canvas,
boardId: string,
onImageAdded: OnImageAdded
): () => void {
async function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item.type.startsWith('image/')) continue;
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
// Place at canvas center
const vpt = canvas.viewportTransform!;
const cx = (canvas.width! / 2 - vpt[4]) / vpt[0];
const cy = (canvas.height! / 2 - vpt[5]) / vpt[3];
const placeholder = new Rect({
left: cx - 100,
top: cy - 75,
width: 200,
height: 150,
fill: '#3d3d3d',
stroke: '#4a9eff',
strokeWidth: 2,
strokeDashArray: [8, 4],
selectable: false,
evented: false,
});
canvas.add(placeholder);
canvas.requestRenderAll();
try {
const res = await uploadImage(boardId, file);
const imgData = res.data.image || res.data;
const url = imgData.public_url;
canvas.remove(placeholder);
const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' });
imgEl.set({
left: cx - (imgEl.width! * (imgEl.scaleX || 1)) / 2,
top: cy - (imgEl.height! * (imgEl.scaleY || 1)) / 2,
id: imgData.id,
} as any);
const maxDim = 600;
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
imgEl.scale(scale);
}
canvas.add(imgEl);
canvas.requestRenderAll();
onImageAdded();
} catch (err) {
console.error('Image paste upload failed:', err);
canvas.remove(placeholder);
canvas.requestRenderAll();
}
}
}
document.addEventListener('paste', onPaste);
return () => {
document.removeEventListener('paste', onPaste);
};
}
+213
View File
@@ -0,0 +1,213 @@
import { Canvas, FabricObject } from 'fabric';
import { Socket } from 'socket.io-client';
/**
* Full-scene sync approach (like Excalidraw):
*
* 1. On any change → broadcast full canvas JSON (throttled)
* 2. On receive → loadFromJSON to replace entire canvas
* 3. During drag → lightweight position events for smooth real-time
* 4. No per-object tracking, no ID matching race conditions
*
* Images are URLs (stored in MinIO), so canvas JSON stays small.
*/
let _suppress = false;
export function isRemoteUpdate(): boolean {
return _suppress;
}
export function suppressBroadcasts() {
_suppress = true;
}
export function resumeBroadcasts() {
_suppress = false;
}
export function setupSync(
canvas: Canvas,
socket: Socket,
boardId: string
): () => void {
let sceneTimer: ReturnType<typeof setTimeout> | null = null;
let moveTimer: ReturnType<typeof setTimeout> | null = null;
let userInteracting = false;
let pendingScene: any = null;
const SCENE_THROTTLE = 300; // ms
const MOVE_THROTTLE = 50; // ms
// ---- Ensure objects have IDs ----
function ensureId(obj: FabricObject): string {
if (!(obj as any).id) {
(obj as any).id = crypto.randomUUID();
}
return (obj as any).id;
}
// ---- BROADCAST: full scene (throttled) ----
function scheduleBroadcast() {
if (_suppress) return;
if (sceneTimer) clearTimeout(sceneTimer);
sceneTimer = setTimeout(() => {
sceneTimer = null;
if (_suppress) return;
// Ensure all objects have IDs before serializing
canvas.getObjects().forEach(ensureId);
const scene = (canvas as any).toJSON(['id']);
socket.emit('scene:update', { boardId, scene });
}, SCENE_THROTTLE);
}
// ---- BROADCAST: lightweight transform during drag ----
function emitTransform(obj: FabricObject) {
if (_suppress) return;
const id = (obj as any).id;
if (!id) return;
socket.emit('object:transform', {
boardId, objectId: id,
left: obj.left, top: obj.top,
scaleX: obj.scaleX, scaleY: obj.scaleY,
angle: obj.angle,
});
}
// ---- RECEIVE: full scene ----
function applyRemoteScene(scene: any) {
if (userInteracting) {
// Defer — apply when user finishes interaction
pendingScene = scene;
return;
}
doApplyScene(scene);
}
function doApplyScene(scene: any) {
_suppress = true;
canvas.loadFromJSON(scene)
.then(() => {
canvas.requestRenderAll();
// Small delay for any deferred Fabric events
setTimeout(() => { _suppress = false; }, 50);
})
.catch((err: any) => {
console.error('[sync] loadFromJSON failed:', err);
_suppress = false;
});
}
// ---- RECEIVE: lightweight transform ----
function applyRemoteTransform(payload: any) {
if (payload.boardId !== boardId) return;
const obj = canvas.getObjects().find((o) => (o as any).id === payload.objectId);
if (!obj) return;
_suppress = true;
obj.set({
left: payload.left,
top: payload.top,
scaleX: payload.scaleX,
scaleY: payload.scaleY,
angle: payload.angle,
});
obj.setCoords();
canvas.requestRenderAll();
_suppress = false;
}
// ---- Canvas event handlers ----
function onObjectAdded() {
if (!_suppress) scheduleBroadcast();
}
function onObjectModified() {
if (!_suppress) scheduleBroadcast();
}
function onObjectRemoved() {
if (!_suppress) scheduleBroadcast();
}
function onPathCreated() {
if (!_suppress) scheduleBroadcast();
}
function onObjectMoving(e: any) {
if (_suppress) return;
// Throttle transform events
if (moveTimer) return;
emitTransform(e.target);
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
}
function onMouseDown() {
userInteracting = true;
}
function onMouseUp() {
userInteracting = false;
if (pendingScene) {
doApplyScene(pendingScene);
pendingScene = null;
}
}
// ---- Bind canvas events ----
canvas.on('object:added', onObjectAdded);
canvas.on('object:modified', onObjectModified);
canvas.on('object:removed', onObjectRemoved);
canvas.on('path:created', onPathCreated);
canvas.on('object:moving', onObjectMoving);
canvas.on('object:scaling', onObjectMoving);
canvas.on('object:rotating', onObjectMoving);
canvas.on('mouse:down', onMouseDown);
canvas.on('mouse:up', onMouseUp);
// ---- Bind socket events ----
function onSceneUpdate(payload: any) {
if (payload.boardId !== boardId) return;
applyRemoteScene(payload.scene);
}
socket.on('scene:update', onSceneUpdate);
socket.on('object:transform', applyRemoteTransform);
// ---- Join room ----
socket.emit('board:join', { boardId }, (response: any) => {
if (response?.users) {
socket.emit('room:users', { users: response.users });
}
});
// ---- Cleanup ----
return () => {
canvas.off('object:added', onObjectAdded);
canvas.off('object:modified', onObjectModified);
canvas.off('object:removed', onObjectRemoved);
canvas.off('path:created', onPathCreated);
canvas.off('object:moving', onObjectMoving);
canvas.off('object:scaling', onObjectMoving);
canvas.off('object:rotating', onObjectMoving);
canvas.off('mouse:down', onMouseDown);
canvas.off('mouse:up', onMouseUp);
socket.off('scene:update', onSceneUpdate);
socket.off('object:transform', applyRemoteTransform);
socket.emit('board:leave', { boardId });
if (sceneTimer) clearTimeout(sceneTimer);
if (moveTimer) clearTimeout(moveTimer);
};
}
+136
View File
@@ -0,0 +1,136 @@
import { Canvas, PencilBrush, IText, FabricObject } from 'fabric';
export enum ToolType {
SELECT = 'SELECT',
PAN = 'PAN',
PEN = 'PEN',
TEXT = 'TEXT',
ERASER = 'ERASER',
}
export interface ToolOptions {
color?: string;
strokeWidth?: number;
fontSize?: number;
}
const defaultOptions: ToolOptions = {
color: '#ffffff',
strokeWidth: 4,
fontSize: 24,
};
type CleanupFn = (() => void) | null;
export function activateTool(
canvas: Canvas,
tool: ToolType,
options: ToolOptions = {}
): CleanupFn {
const opts = { ...defaultOptions, ...options };
// Reset common state
canvas.isDrawingMode = false;
canvas.selection = false;
canvas.defaultCursor = 'default';
canvas.hoverCursor = 'default';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = false;
obj.evented = false;
});
switch (tool) {
case ToolType.SELECT: {
canvas.selection = true;
canvas.defaultCursor = 'default';
canvas.hoverCursor = 'move';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = true;
obj.evented = true;
});
return null;
}
case ToolType.PAN: {
canvas.defaultCursor = 'grab';
canvas.hoverCursor = 'grab';
// Pan is handled by FabricCanvas component directly
return null;
}
case ToolType.PEN: {
canvas.isDrawingMode = true;
const brush = new PencilBrush(canvas);
brush.color = opts.color!;
brush.width = opts.strokeWidth!;
canvas.freeDrawingBrush = brush;
return null;
}
case ToolType.TEXT: {
canvas.defaultCursor = 'text';
canvas.hoverCursor = 'text';
const handler = (e: any) => {
const pointer = canvas.getScenePoint(e.e);
const text = new IText('Type here', {
left: pointer.x,
top: pointer.y,
fontSize: opts.fontSize!,
fill: opts.color!,
fontFamily: 'sans-serif',
editable: true,
});
canvas.add(text);
canvas.setActiveObject(text);
text.enterEditing();
text.selectAll();
// Remove handler after placing text
canvas.off('mouse:down', handler);
};
canvas.on('mouse:down', handler);
return () => {
canvas.off('mouse:down', handler);
};
}
case ToolType.ERASER: {
canvas.defaultCursor = 'crosshair';
canvas.hoverCursor = 'crosshair';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = false;
obj.evented = true;
});
const handler = (e: any) => {
const target = e.target;
if (target) {
canvas.remove(target);
canvas.requestRenderAll();
}
};
canvas.on('mouse:down', handler);
return () => {
canvas.off('mouse:down', handler);
};
}
default:
return null;
}
}
export const toolShortcuts: Record<string, ToolType> = {
v: ToolType.SELECT,
h: ToolType.PAN,
p: ToolType.PEN,
t: ToolType.TEXT,
e: ToolType.ERASER,
'1': ToolType.SELECT,
'2': ToolType.PAN,
'3': ToolType.PEN,
'4': ToolType.TEXT,
'5': ToolType.ERASER,
};