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:
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user