Files
refboard-ayon/frontend/src/canvas/sync.ts
T

312 lines
10 KiB
TypeScript

import type { Socket } from 'socket.io-client';
import type { SceneManager, SceneItem } from './SceneManager';
import type { SceneData, AnySceneObject, ImageObject } from './scene-format';
import { applyImageDisplayTransform } from './imageTransforms';
/**
* Sync protocol v3 — Excalidraw-inspired incremental element sync.
*
* 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).
*
* 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: (items: SceneItem | 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,
options?: SyncOptions,
): SyncHandle {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let moveTimer: ReturnType<typeof setTimeout> | null = null;
let receiving = false;
let localVersion = 0;
let remoteVersion = 0;
const DEBOUNCE_MS = 500;
const MOVE_THROTTLE = 50;
// Track broadcasted element versions (like Excalidraw's broadcastedElementVersions)
const broadcastedVersions: Map<string, number> = new Map();
// 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: 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(itemOrItems: SceneItem | SceneItem[]) {
if (receiving) return;
if (moveTimer) return;
const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
const transforms = items.map(({ id, data }) => ({
objectId: id,
x: data.x,
y: data.y,
sx: data.sx,
sy: data.sy,
angle: data.angle,
flipX: data.flipX,
flipY: data.flipY,
}));
socket.emit('object:transform', { boardId, transforms });
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
}
// ---- 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) => {
console.error('[sync] loadScene failed:', err);
receiving = false;
});
}
// ---- 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 applyTransform(t: any) {
const item = sceneManager.getById(t.objectId);
if (!item) return;
item.data.x = t.x;
item.data.y = t.y;
item.data.sx = t.sx;
item.data.sy = t.sy;
item.data.angle = t.angle;
if (typeof t.flipX === 'boolean') item.data.flipX = t.flipX;
if (typeof t.flipY === 'boolean') item.data.flipY = t.flipY;
const obj = item.displayObject;
if (item.type === 'image') {
applyImageDisplayTransform(obj, item.data as ImageObject);
} else {
obj.position.set(t.x, t.y);
obj.scale.set(t.sx, t.sy);
obj.angle = t.angle;
}
options?.onRemoteTransform?.(item);
}
function onTransformReceived(payload: any) {
if (payload.boardId !== boardId) return;
// Batched format: { transforms: [...] }
if (Array.isArray(payload.transforms)) {
for (const t of payload.transforms) applyTransform(t);
} else if (payload.objectId) {
// Legacy single-item format
applyTransform(payload);
}
}
// ---- Wire up SceneManager onChange → debounced full sync -------------------
const prevOnChange = sceneManager.onChange;
sceneManager.onChange = () => {
prevOnChange?.();
// Structural change — schedule a full scene sync (debounced).
// Incremental element sync is handled explicitly by tools via broadcastElements().
broadcastSceneDebounced();
};
// ---- 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 (and rejoin on reconnect) ----------------------------------
function joinRoom() {
socket.emit('board:join', { boardId }, (response: any) => {
if (response?.users) {
socket.emit('room:users', { users: response.users });
}
});
}
// 'connect' fires on both the initial connection and every reconnect,
// ensuring the server always has this client in the board room.
socket.on('connect', joinRoom);
// Emit immediately if already connected (socket was connected before setupSync ran).
if (socket.connected) {
joinRoom();
}
// ---- Return handle --------------------------------------------------------
return {
broadcastTransform,
broadcastSceneNow,
broadcastElements(ids: string[]) {
for (const id of ids) bumpElementVersion(id);
broadcastElements(ids);
},
cleanup: () => {
sceneManager.onChange = prevOnChange;
socket.off('connect', joinRoom);
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);
},
};
}