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