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
+196 -51
View File
@@ -1,47 +1,128 @@
import type { Socket } from 'socket.io-client';
import type { SceneManager, SceneItem } from './SceneManager';
import type { SceneData } from './scene-format';
import type { SceneData, AnySceneObject } from './scene-format';
/**
* Full-scene sync (v2 format, PixiJS / SceneManager):
* Sync protocol v3 — Excalidraw-inspired incremental element sync.
*
* 1. On any change → broadcast serialized SceneData (throttled 300ms)
* 2. On receive → sceneManager.loadScene() (diff-based, no flicker)
* 3. During drag → lightweight transform events (throttled 50ms)
* Three event tiers:
* 1. scene:update — full scene snapshot. Sent on join (INIT) and periodically
* to correct any drift. Receiver does full reconciliation via loadScene().
* 2. element:update — array of changed elements only. Sent on any element
* change (add, modify, draw). Receiver merges incrementally — no full scene
* diff needed. Each element carries a `_v` version; receiver skips stale.
* 3. element:remove — array of removed element IDs. Receiver deletes them.
* 4. object:transform — ephemeral position/scale during drag (unchanged).
*
* Diff-based loading doesn't trigger change events for unchanged objects,
* so no suppress/resume logic is needed.
* During freehand drawing, only the single drawing element is sent via
* element:update every ~60ms (one frame) — tiny payload, no lag.
*/
export interface SyncHandle {
cleanup: () => void;
broadcastTransform: (item: SceneItem) => void;
/** Force an immediate full scene broadcast (call after structural changes). */
broadcastSceneNow: () => void;
/** Broadcast only specific changed elements (lightweight). */
broadcastElements: (ids: string[]) => void;
}
export interface SyncOptions {
onRemoteTransform?: (item: SceneItem) => void;
}
export function setupSync(
sceneManager: SceneManager,
socket: Socket,
boardId: string,
): () => void {
let sceneTimer: ReturnType<typeof setTimeout> | null = null;
options?: SyncOptions,
): SyncHandle {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let moveTimer: ReturnType<typeof setTimeout> | null = null;
let receiving = false; // true while applying a remote scene/transform
const SCENE_THROTTLE = 300; // ms
const MOVE_THROTTLE = 50; // ms
let receiving = false;
let localVersion = 0;
let remoteVersion = 0;
const DEBOUNCE_MS = 500;
const MOVE_THROTTLE = 50;
// ---- BROADCAST: full scene (throttled) --------------------------------
// Track broadcasted element versions (like Excalidraw's broadcastedElementVersions)
const broadcastedVersions: Map<string, number> = new Map();
function broadcastScene() {
if (receiving) return;
if (sceneTimer) clearTimeout(sceneTimer);
sceneTimer = setTimeout(() => {
sceneTimer = null;
if (receiving) return;
const scene = sceneManager.serialize();
socket.emit('scene:update', { boardId, scene });
}, SCENE_THROTTLE);
// Per-element version counter — bumped whenever an element changes locally
const elementVersions: Map<string, number> = new Map();
function bumpElementVersion(id: string): number {
const v = (elementVersions.get(id) ?? 0) + 1;
elementVersions.set(id, v);
return v;
}
// ---- BROADCAST: lightweight transform during drag ---------------------
// ---- BROADCAST: incremental element update --------------------------------
/** Send only the specified elements. Skips if element hasn't changed since last broadcast. */
function broadcastElements(ids: string[]) {
if (receiving) return;
const elements: (AnySceneObject & { _v: number })[] = [];
for (const id of ids) {
const item = sceneManager.getById(id);
if (!item) continue;
const v = elementVersions.get(id) ?? 0;
const lastBroadcasted = broadcastedVersions.get(id) ?? -1;
if (v <= lastBroadcasted) continue;
elements.push({ ...item.data, _v: v });
broadcastedVersions.set(id, v);
}
if (elements.length === 0) return;
socket.emit('element:update', { boardId, elements });
}
/** Broadcast all elements that changed since last broadcast. */
function broadcastChangedElements() {
if (receiving) return;
const ids: string[] = [];
for (const [id, v] of elementVersions) {
if (v > (broadcastedVersions.get(id) ?? -1)) {
ids.push(id);
}
}
if (ids.length > 0) broadcastElements(ids);
}
// ---- BROADCAST: full scene (for INIT / periodic resync) -------------------
function broadcastSceneNow() {
if (receiving) return;
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
localVersion++;
const scene = sceneManager.serialize();
// Update all broadcasted versions
for (const obj of scene.objects) {
const v = elementVersions.get(obj.id) ?? 0;
broadcastedVersions.set(obj.id, v);
}
socket.emit('scene:update', { boardId, scene, version: localVersion });
}
function broadcastSceneDebounced() {
if (receiving) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
broadcastSceneNow();
}, DEBOUNCE_MS);
}
// ---- BROADCAST: lightweight transform during drag -------------------------
function broadcastTransform(item: SceneItem) {
if (receiving) return;
if (moveTimer) return; // throttled
if (moveTimer) return;
const { id, data } = item;
socket.emit('object:transform', {
boardId,
@@ -52,22 +133,34 @@ export function setupSync(
sy: data.sy,
angle: data.angle,
});
moveTimer = setTimeout(() => {
moveTimer = null;
}, MOVE_THROTTLE);
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
broadcastSceneDebounced();
}
// ---- RECEIVE: full scene ----------------------------------------------
// ---- RECEIVE: full scene --------------------------------------------------
function onSceneReceived(payload: any) {
if (payload.boardId !== boardId) return;
const scene: SceneData = payload.scene;
if (!scene || scene.v !== 2) return;
const incomingVersion = payload.version ?? 0;
if (incomingVersion > 0 && incomingVersion <= remoteVersion) return;
remoteVersion = incomingVersion;
if (incomingVersion >= localVersion) localVersion = incomingVersion;
receiving = true;
sceneManager
.loadScene(scene)
.then(() => {
// Update element versions from received data
for (const obj of scene.objects) {
const v = (obj as any)._v;
if (typeof v === 'number') {
const current = elementVersions.get(obj.id) ?? 0;
if (v > current) elementVersions.set(obj.id, v);
}
}
receiving = false;
})
.catch((err: any) => {
@@ -76,42 +169,91 @@ export function setupSync(
});
}
// ---- RECEIVE: lightweight transform -----------------------------------
// ---- RECEIVE: incremental element update ----------------------------------
function onElementUpdate(payload: any) {
if (payload.boardId !== boardId) return;
const elements: (AnySceneObject & { _v?: number })[] = payload.elements;
if (!Array.isArray(elements) || elements.length === 0) return;
for (const data of elements) {
const incomingV = data._v ?? 0;
// Clean the _v field before storing
const cleanData = { ...data };
delete (cleanData as any)._v;
const existing = sceneManager.getById(data.id);
if (existing) {
// Update existing — apply incremental merge
sceneManager._updateItem(existing, cleanData);
} else {
// New element — create it
sceneManager._createItem(cleanData, false);
}
// Track the version
if (incomingV > 0) {
const current = elementVersions.get(data.id) ?? 0;
if (incomingV > current) elementVersions.set(data.id, incomingV);
}
}
sceneManager._applyZOrder();
}
// ---- RECEIVE: element removal ---------------------------------------------
function onElementRemove(payload: any) {
if (payload.boardId !== boardId) return;
const ids: string[] = payload.ids;
if (!Array.isArray(ids)) return;
for (const id of ids) {
sceneManager.removeItem(id, true);
elementVersions.delete(id);
broadcastedVersions.delete(id);
}
}
// ---- RECEIVE: lightweight transform ---------------------------------------
function onTransformReceived(payload: any) {
if (payload.boardId !== boardId) return;
const item = sceneManager.getById(payload.objectId);
if (!item) return;
// Update data model
item.data.x = payload.x;
item.data.y = payload.y;
item.data.sx = payload.sx;
item.data.sy = payload.sy;
item.data.angle = payload.angle;
// Update display object
const obj = item.displayObject;
obj.position.set(payload.x, payload.y);
obj.scale.set(payload.sx, payload.sy);
obj.angle = payload.angle;
// No onChange — this is a remote update
options?.onRemoteTransform?.(item);
}
// ---- Wire up SceneManager onChange → broadcastScene --------------------
// ---- Wire up SceneManager onChange → debounced full sync -------------------
const prevOnChange = sceneManager.onChange;
sceneManager.onChange = () => {
prevOnChange?.();
broadcastScene();
// Structural change — schedule a full scene sync (debounced).
// Incremental element sync is handled explicitly by tools via broadcastElements().
broadcastSceneDebounced();
};
// ---- Bind socket events -----------------------------------------------
// ---- Bind socket events ---------------------------------------------------
socket.on('scene:update', onSceneReceived);
socket.on('element:update', onElementUpdate);
socket.on('element:remove', onElementRemove);
socket.on('object:transform', onTransformReceived);
// ---- Join room --------------------------------------------------------
// ---- Join room ------------------------------------------------------------
socket.emit('board:join', { boardId }, (response: any) => {
if (response?.users) {
@@ -119,21 +261,24 @@ export function setupSync(
}
});
// ---- Cleanup ----------------------------------------------------------
// ---- Return handle --------------------------------------------------------
return () => {
// Restore previous onChange
sceneManager.onChange = prevOnChange;
socket.off('scene:update', onSceneReceived);
socket.off('object:transform', onTransformReceived);
socket.emit('board:leave', { boardId });
if (sceneTimer) clearTimeout(sceneTimer);
if (moveTimer) clearTimeout(moveTimer);
return {
broadcastTransform,
broadcastSceneNow,
broadcastElements(ids: string[]) {
for (const id of ids) bumpElementVersion(id);
broadcastElements(ids);
},
cleanup: () => {
sceneManager.onChange = prevOnChange;
socket.off('scene:update', onSceneReceived);
socket.off('element:update', onElementUpdate);
socket.off('element:remove', onElementRemove);
socket.off('object:transform', onTransformReceived);
socket.emit('board:leave', { boardId });
if (debounceTimer) clearTimeout(debounceTimer);
if (moveTimer) clearTimeout(moveTimer);
},
};
}
// Note: broadcastTransform for drag events will be wired via Editor integration.
// SelectionManager / TransformBox will call it directly once connected.