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
+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,
};