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:
+196
-51
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user