feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes

- Fix transform box not updating after alignment/arrangement/normalize/flip operations
  (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper)
- Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll)
- Add adaptive dot grid background that responds to zoom/pan
- Reduce snap guide threshold from 8px to 4px for subtler snapping
- Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit
- Offset multiple dropped images so they don't overlap
- Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite,
  LaserPointer, context-menu-items, SelectionToolbar, Minimap
- Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup,
  useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode)
- Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
Hiren Kangad
2026-03-10 03:58:53 +05:30
parent cfd7adf73d
commit e9509ef7b0
41 changed files with 4171 additions and 705 deletions
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { getBoard } from '../api';
interface BoardLoaderResult {
boardData: any;
loading: boolean;
error: string;
}
/**
* Loads board data by ID with cancellation support.
*/
export function useBoardLoader(boardId: string | undefined): BoardLoaderResult {
const [boardData, setBoardData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
try {
if (!boardId) {
setError('No board specified');
setLoading(false);
return;
}
const res = await getBoard(boardId);
if (!cancelled) {
setBoardData(res.data);
setLoading(false);
}
} catch (err: any) {
if (!cancelled) {
setError(err.response?.data?.error || 'Failed to load board');
setLoading(false);
}
}
}
load();
return () => { cancelled = true; };
}, [boardId]);
return { boardData, loading, error };
}
+67
View File
@@ -6,6 +6,8 @@ import { setupSync, SyncHandle } from '../canvas/sync';
import { setupDragDrop, setupPaste } from '../canvas/image-drop';
import { UndoManager } from '../canvas/history';
import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket';
interface OnlineUser {
@@ -57,6 +59,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const dropCleanupRef = useRef<(() => void) | null>(null);
const pasteCleanupRef = useRef<(() => void) | null>(null);
const laserCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!boardData || !resolvedBoardId) return;
@@ -70,6 +73,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const selection = new SelectionManager(viewport, scene);
selectionRef.current = selection;
// Wire snap guides to transform box
selection.transformBox.setSnapGuides(selection.snapGuides);
// Wire selection change to update layer panel state
selection.onSelectionChange = (ids: string[]) => {
setSelectedLayerIds(ids);
@@ -176,6 +182,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
cursorTimer = setTimeout(() => { cursorTimer = null; }, 33);
};
viewport.on('pointermove', onPointerMove);
// ---- Laser pointer (hold L key) ----
const laserColorHex = userColor(user?.id || '');
const laserColorNum = parseInt(laserColorHex.replace('#', ''), 16);
const laser = new LaserPointer(viewport, laserColorNum);
let laserTimer: ReturnType<typeof setTimeout> | null = null;
const onLaserMove = (e: any) => {
if (!laser.isActive) return;
const world = viewport.toWorld(e.global.x, e.global.y);
laser.addPoint(world.x, world.y);
// Throttle broadcast to ~50ms
if (laserTimer) return;
socket.volatile.emit('laser:move', {
boardId: resolvedBoardId,
points: [{ x: world.x, y: world.y }],
});
laserTimer = setTimeout(() => { laserTimer = null; }, 50);
};
viewport.on('pointermove', onLaserMove);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'l' || e.key === 'L') {
if (laser.isActive) return;
laser.start();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'l' || e.key === 'L') {
laser.stop();
socket.emit('laser:stop', { boardId: resolvedBoardId });
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
laserCleanupRef.current = () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
laser.destroy();
};
// Listen for remote laser events
socket.on('laser:move', (data: any) => {
if (data.boardId !== resolvedBoardId) return;
const uid = data.userId;
if (!uid) return;
const color = parseInt(userColor(uid).replace('#', ''), 16);
laser.addRemotePoints(uid, data.points, color);
});
socket.on('laser:stop', (_data: any) => {
// Remote user stopped — points will fade naturally
});
socket.on('user:left', (data: any) => {
const uid = data.userId || data.id;
if (uid) {
laser.removeRemote(uid);
}
});
}
// Setup drag/drop and paste
@@ -208,6 +273,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
inboxZoneRef.current = null;
}
syncRef.current?.cleanup();
laserCleanupRef.current?.();
laserCleanupRef.current = null;
dropCleanupRef.current?.();
pasteCleanupRef.current?.();
disconnectSocket();
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Socket } from 'socket.io-client';
import type { Viewport } from 'pixi-viewport';
interface FollowState {
followingUserId: string | null;
followingDisplayName: string | null;
}
interface UseFollowModeProps {
socket: Socket | null;
boardId: string | undefined;
getViewport: () => Viewport | null;
}
interface UseFollowModeReturn {
followingUserId: string | null;
followingDisplayName: string | null;
startFollowing: (userId: string, displayName: string) => void;
stopFollowing: () => void;
}
/**
* Follow mode — click a user's avatar to track their viewport in real-time.
* Broadcasts own viewport position so others can follow us.
* Pan/zoom manually to break free.
*/
export function useFollowMode({ socket, boardId, getViewport }: UseFollowModeProps): UseFollowModeReturn {
const [state, setState] = useState<FollowState>({
followingUserId: null,
followingDisplayName: null,
});
const followRef = useRef<string | null>(null);
const ignoreNextMove = useRef(false);
// Broadcast own viewport position (always, so others can follow us)
useEffect(() => {
if (!socket || !boardId) return;
const timer = setInterval(() => {
const vp = getViewport();
if (!vp) return;
socket.volatile.emit('viewport:sync', {
boardId,
x: vp.center.x,
y: vp.center.y,
scale: vp.scale.x,
});
}, 150);
return () => clearInterval(timer);
}, [socket, boardId, getViewport]);
// Listen for viewport:sync from followed user and animate to match
useEffect(() => {
if (!socket) return;
const onViewportSync = (data: { boardId: string; userId: string; x: number; y: number; scale: number }) => {
if (data.boardId !== boardId) return;
const targetId = followRef.current;
if (!targetId || data.userId !== targetId) return;
const vp = getViewport();
if (!vp) return;
ignoreNextMove.current = true;
vp.animate({
time: 200,
position: { x: data.x, y: data.y },
scale: data.scale,
ease: 'easeOutQuad',
callbackOnComplete: () => {
// Brief delay before re-enabling break-free detection
setTimeout(() => { ignoreNextMove.current = false; }, 50);
},
});
};
socket.on('viewport:sync', onViewportSync);
return () => { socket.off('viewport:sync', onViewportSync); };
}, [socket, boardId, getViewport]);
// Detect manual pan/zoom to break free from follow mode
useEffect(() => {
const vp = getViewport();
if (!vp) return;
const onMoved = () => {
if (ignoreNextMove.current) return;
if (followRef.current) {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
}
};
vp.on('moved-end', onMoved);
return () => { vp.off('moved-end', onMoved); };
}, [getViewport, socket, boardId]);
const startFollowing = useCallback((userId: string, displayName: string) => {
// Toggle: if already following this user, stop
if (followRef.current === userId) {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
return;
}
followRef.current = userId;
setState({ followingUserId: userId, followingDisplayName: displayName });
socket?.emit('follow:start', { boardId, targetUserId: userId });
}, [socket, boardId]);
const stopFollowing = useCallback(() => {
followRef.current = null;
setState({ followingUserId: null, followingDisplayName: null });
socket?.emit('follow:stop', { boardId });
}, [socket, boardId]);
return {
followingUserId: state.followingUserId,
followingDisplayName: state.followingDisplayName,
startFollowing,
stopFollowing,
};
}
+161
View File
@@ -0,0 +1,161 @@
import { useCallback, useRef } from 'react';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SelectionManager } from '../canvas/SelectionManager';
import type { SceneItem } from '../canvas/SceneManager';
import type { GroupObject } from '../canvas/scene-format';
interface LayerItem {
id: string;
name: string;
type: string;
visible: boolean;
locked: boolean;
isGroup: boolean;
children?: LayerItem[];
}
interface LayerPanelDeps {
canvasRef: React.RefObject<PixiCanvasHandle | null>;
selectionRef: React.RefObject<SelectionManager | null>;
onCanvasChange: () => void;
}
function autoName(item: SceneItem, i: number, counters: Record<string, number>): string {
if (item.data.name) return item.data.name;
const type = item.data.type;
if (type === 'image') {
counters.image = (counters.image || 0) + 1;
return `Image ${counters.image}`;
}
if (type === 'text') {
const textData = item.data as any;
return (textData.text || 'Text').slice(0, 20);
}
if (type === 'group') {
const groupData = item.data as GroupObject;
return `Group (${groupData.children?.length || 0})`;
}
return `${type} ${i + 1}`;
}
function buildLayerItem(
item: SceneItem,
i: number,
counters: Record<string, number>,
getScene: () => ReturnType<PixiCanvasHandle['getScene']>,
): LayerItem {
const isGroup = item.data.type === 'group';
let children: LayerItem[] | undefined;
if (isGroup) {
const groupData = item.data as GroupObject;
const scene = getScene();
if (scene && groupData.children) {
children = groupData.children.map((childId, ci) => {
const childItem = scene.getById(childId);
if (childItem) return buildLayerItem(childItem, ci, counters, getScene);
return { id: childId, name: `Missing ${childId.slice(0, 6)}`, type: 'unknown', visible: true, locked: false, isGroup: false };
});
}
}
return {
id: item.id,
name: autoName(item, i, counters),
type: item.data.type,
visible: item.data.visible,
locked: item.data.locked,
isGroup,
children,
};
}
/**
* Layer panel handlers — provides refresh function and all layer panel callbacks.
*/
export function useLayerPanel(deps: LayerPanelDeps) {
const { canvasRef, selectionRef, onCanvasChange } = deps;
const nameCounters = useRef<Record<string, number>>({});
const refreshLayers = useCallback((): LayerItem[] => {
const scene = canvasRef.current?.getScene();
if (!scene) return [];
nameCounters.current = {};
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
return items.map((item, i) =>
buildLayerItem(item, i, nameCounters.current, () => canvasRef.current?.getScene() ?? null),
);
}, [canvasRef]);
const onSelect = useCallback((id: string) => {
selectionRef.current?.selectOnly(id);
}, [selectionRef]);
const onToggleVisible = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.visible = !item.data.visible;
item.displayObject.visible = item.data.visible;
onCanvasChange();
}
}, [canvasRef, onCanvasChange]);
const onToggleLock = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.locked = !item.data.locked;
item.displayObject.eventMode = item.data.locked ? 'none' : 'static';
}
}, [canvasRef]);
const onReorder = useCallback((from: number, to: number) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const items = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
if (from < 0 || from >= items.length || to < 0 || to >= items.length) return;
const tmp = items[from].data.z;
items[from].data.z = items[to].data.z;
items[to].data.z = tmp;
scene._applyZOrder();
onCanvasChange();
}, [canvasRef, onCanvasChange]);
const onDelete = useCallback((id: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
scene.removeItem(id, true);
selectionRef.current?.clear();
onCanvasChange();
}, [canvasRef, selectionRef, onCanvasChange]);
const onRename = useCallback((id: string, name: string) => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
const item = scene.getById(id);
if (item) {
item.data.name = name;
// Also update frame label for groups
if (item.data.type === 'group') {
(item.data as any).label = name;
if (item.displayObject && 'setLabel' in item.displayObject) {
(item.displayObject as any).setLabel(name);
}
}
onCanvasChange(); // Broadcast rename to other clients
}
}, [canvasRef, onCanvasChange]);
return {
refreshLayers,
layerHandlers: {
onSelect,
onToggleVisible,
onToggleLock,
onReorder,
onDelete,
onRename,
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useRef } from 'react';
import { saveCanvas } from '../api';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SaveStatus } from '../components/StatusBar';
interface SaveManagerOptions {
resolvedBoardId: string | undefined;
isPublicView?: boolean;
canvasRef: React.RefObject<PixiCanvasHandle | null>;
setSaveStatus: (s: SaveStatus) => void;
}
/**
* Debounced save with thumbnail generation from PixiJS renderer.
*/
export function useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }: SaveManagerOptions) {
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleSave = useCallback(() => {
if (!resolvedBoardId || isPublicView) return;
setSaveStatus('unsaved');
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
const scene = canvasRef.current?.getScene();
if (!scene) return;
setSaveStatus('saving');
try {
const state = JSON.stringify(scene.serialize());
// Generate thumbnail from PixiJS renderer
let thumbnail: string | undefined;
try {
const app = canvasRef.current?.getApp();
const viewport = canvasRef.current?.getViewport();
if (app?.renderer?.extract && viewport) {
const fullCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
const thumbMax = 400;
const sw = fullCanvas.width;
const sh = fullCanvas.height;
if (sw > 0 && sh > 0) {
const ratio = Math.min(thumbMax / sw, thumbMax / sh, 1);
const tw = Math.round(sw * ratio);
const th = Math.round(sh * ratio);
const offscreen = document.createElement('canvas');
offscreen.width = tw;
offscreen.height = th;
const ctx = offscreen.getContext('2d');
if (ctx) {
ctx.drawImage(fullCanvas, 0, 0, tw, th);
thumbnail = offscreen.toDataURL('image/webp', 0.6);
}
}
}
} catch (thumbErr) {
console.warn('Thumbnail generation failed:', thumbErr);
}
await saveCanvas(resolvedBoardId, state, thumbnail);
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
}
}, 2000);
}, [resolvedBoardId, isPublicView, canvasRef, setSaveStatus]);
return { scheduleSave, saveTimerRef };
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect } from 'react';
import type { PixiCanvasHandle } from '../canvas/PixiCanvas';
import type { SelectionManager } from '../canvas/SelectionManager';
import type { UndoManager } from '../canvas/history';
import type { SceneItem } from '../canvas/SceneManager';
import { getItemWorldBounds } from '../canvas/SceneManager';
import { ToolType, toolShortcuts } from '../canvas/tools';
import { ShortcutContext, matchesShortcut, sortBySpecificity } from '../canvas/shortcuts';
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
import { writeCanvasToClipboard, pasteFromSystemClipboard } from '../canvas/clipboard';
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
const sortedShortcuts = sortBySpecificity(shortcutDefs);
interface ShortcutHandlerDeps {
canvasRef: React.RefObject<PixiCanvasHandle | null>;
selectionRef: React.RefObject<SelectionManager | null>;
undoRef: React.RefObject<UndoManager | null>;
clipboardRef: React.MutableRefObject<SceneItem[]>;
resolvedBoardId: string | undefined;
onCanvasChange: () => void;
showToast: (msg: string) => void;
refreshLayers: () => void;
handleGroup: () => void;
handleUngroup: () => void;
setActiveTool: (t: ToolType) => void;
setCanUndo: (v: boolean) => void;
setCanRedo: (v: boolean) => void;
setZoom: (z: number) => void;
setShowGrid: React.Dispatch<React.SetStateAction<boolean>>;
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Keyboard shortcut handler — registry-based, delegates to shortcut-definitions.
*/
export function useShortcutHandler(deps: ShortcutHandlerDeps) {
const {
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
onCanvasChange, showToast, refreshLayers,
handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode,
} = deps;
useEffect(() => {
async function onKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const scene = canvasRef.current?.getScene();
const selection = selectionRef.current;
const viewport = canvasRef.current?.getViewport();
if (!scene || !selection || !viewport) return;
// Tool shortcuts (bare keys 1-5, v/h/p/t/e -- no modifiers)
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
const tool = toolShortcuts[e.key.toLowerCase()];
if (tool) {
setActiveTool(tool);
return;
}
}
// Build context for shortcut handlers
const ctx: ShortcutContext = {
scene,
selection,
history: undoRef.current!,
viewport,
onChange: onCanvasChange,
clipboardRef,
writeCanvasToClipboard: async (items?: SceneItem[]) => {
try {
const app = canvasRef.current?.getApp() ?? null;
await writeCanvasToClipboard(app, viewport, items);
} catch (err: any) {
showToast('Copy failed: ' + (err.message || 'clipboard not available'));
}
},
showToast,
fitAll: () => canvasRef.current?.fitAll(),
setActiveTool,
setCanUndo,
setCanRedo,
refreshLayers,
handleGroup,
handleUngroup,
toggleGrid: () => setShowGrid((v) => !v),
toggleShowHelp: () => setShowHelp((v) => !v),
toggleFocusMode: () => setFocusMode((v) => !v),
pasteFromSystemClipboard: async (): Promise<string> => {
if (!resolvedBoardId) return 'No board';
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
if (msg !== 'No image in clipboard') showToast(msg);
return msg;
},
fitSelection: () => {
const vp = canvasRef.current?.getViewport();
if (!vp || !selection) return;
const items = selection.getSelectedItems();
if (items.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const item of items) {
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
if (ix < minX) minX = ix;
if (iy < minY) minY = iy;
if (ix + iw > maxX) maxX = ix + iw;
if (iy + ih > maxY) maxY = iy + ih;
}
const padding = 60;
const contentW = maxX - minX + padding * 2;
const contentH = maxY - minY + padding * 2;
const scaleX = vp.screenWidth / contentW;
const scaleY = vp.screenHeight / contentH;
const targetScale = Math.min(scaleX, scaleY, 5);
vp.animate({
position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
scale: targetScale,
time: 300,
ease: 'easeOutQuad',
});
},
};
// Match against sorted registry (most-specific first)
for (const def of sortedShortcuts) {
if (!matchesShortcut(e, def)) continue;
// Check selection requirements
const activeCount = selection.selectedIds.size;
if (def.needsSelection && activeCount === 0) continue;
if (def.minSelection && activeCount < def.minSelection) continue;
e.preventDefault();
await def.handler(ctx);
// Update zoom display after any action
setZoom(canvasRef.current?.getZoom() ?? 1);
return;
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
onCanvasChange, showToast, refreshLayers,
handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode,
]);
}