Port sync.ts to v2 format with SceneManager

Rewrite sync module to work with SceneManager and v2 scene format
instead of Fabric.js. Key changes:
- setupSync takes SceneManager instead of Canvas
- broadcastScene uses sceneManager.serialize() for v2 SceneData
- broadcastTransform reads from item.data (x,y,sx,sy,angle)
- onSceneReceived uses diff-based loadScene (no flicker, no full reload)
- onTransformReceived updates item.data + displayObject directly
- Remove _suppress/resumeBroadcasts (diff-based loading is safe)
- Remove userInteracting/pendingScene deferral (not needed with diffs)
- Remove all Fabric.js imports and types
This commit is contained in:
Hiren Kangad
2026-03-09 23:21:32 +05:30
parent 634937edc2
commit 9c5056d11e
+78 -164
View File
@@ -1,199 +1,117 @@
import { Canvas, FabricObject } from 'fabric'; import type { Socket } from 'socket.io-client';
import { Socket } from 'socket.io-client'; import type { SceneManager, SceneItem } from './SceneManager';
import type { SceneData } from './scene-format';
/** /**
* Full-scene sync approach (like Excalidraw): * Full-scene sync (v2 format, PixiJS / SceneManager):
* *
* 1. On any change → broadcast full canvas JSON (throttled) * 1. On any change → broadcast serialized SceneData (throttled 300ms)
* 2. On receive → loadFromJSON to replace entire canvas * 2. On receive → sceneManager.loadScene() (diff-based, no flicker)
* 3. During drag → lightweight position events for smooth real-time * 3. During drag → lightweight transform events (throttled 50ms)
* 4. No per-object tracking, no ID matching race conditions
* *
* Images are URLs (stored in MinIO), so canvas JSON stays small. * Diff-based loading doesn't trigger change events for unchanged objects,
* so no suppress/resume logic is needed.
*/ */
let _suppress = false;
export function isRemoteUpdate(): boolean {
return _suppress;
}
export function suppressBroadcasts() {
_suppress = true;
}
export function resumeBroadcasts() {
_suppress = false;
}
export function setupSync( export function setupSync(
canvas: Canvas, sceneManager: SceneManager,
socket: Socket, socket: Socket,
boardId: string boardId: string,
): () => void { ): () => void {
let sceneTimer: ReturnType<typeof setTimeout> | null = null; let sceneTimer: ReturnType<typeof setTimeout> | null = null;
let moveTimer: ReturnType<typeof setTimeout> | null = null; let moveTimer: ReturnType<typeof setTimeout> | null = null;
let userInteracting = false; let receiving = false; // true while applying a remote scene/transform
let pendingScene: any = null;
const SCENE_THROTTLE = 300; // ms const SCENE_THROTTLE = 300; // ms
const MOVE_THROTTLE = 50; // ms const MOVE_THROTTLE = 50; // ms
// ---- Ensure objects have IDs ---- // ---- BROADCAST: full scene (throttled) --------------------------------
function ensureId(obj: FabricObject): string { function broadcastScene() {
if (!(obj as any).id) { if (receiving) return;
(obj as any).id = crypto.randomUUID();
}
return (obj as any).id;
}
// ---- BROADCAST: full scene (throttled) ----
function scheduleBroadcast() {
if (_suppress) return;
if (sceneTimer) clearTimeout(sceneTimer); if (sceneTimer) clearTimeout(sceneTimer);
sceneTimer = setTimeout(() => { sceneTimer = setTimeout(() => {
sceneTimer = null; sceneTimer = null;
if (_suppress) return; if (receiving) return;
// Ensure all objects have IDs before serializing const scene = sceneManager.serialize();
canvas.getObjects().forEach(ensureId);
const scene = (canvas as any).toJSON(['id', 'crossOrigin']);
socket.emit('scene:update', { boardId, scene }); socket.emit('scene:update', { boardId, scene });
}, SCENE_THROTTLE); }, SCENE_THROTTLE);
} }
// ---- BROADCAST: lightweight transform during drag ---- // ---- BROADCAST: lightweight transform during drag ---------------------
function emitTransform(obj: FabricObject) { function broadcastTransform(item: SceneItem) {
if (_suppress) return; if (receiving) return;
const id = (obj as any).id; if (moveTimer) return; // throttled
if (!id) return; const { id, data } = item;
socket.emit('object:transform', { socket.emit('object:transform', {
boardId, objectId: id, boardId,
left: obj.left, top: obj.top, objectId: id,
scaleX: obj.scaleX, scaleY: obj.scaleY, x: data.x,
angle: obj.angle, y: data.y,
sx: data.sx,
sy: data.sy,
angle: data.angle,
}); });
moveTimer = setTimeout(() => {
moveTimer = null;
}, MOVE_THROTTLE);
} }
// ---- RECEIVE: full scene ---- // ---- RECEIVE: full scene ----------------------------------------------
function applyRemoteScene(scene: any) { function onSceneReceived(payload: any) {
if (userInteracting) { if (payload.boardId !== boardId) return;
// Defer — apply when user finishes interaction const scene: SceneData = payload.scene;
pendingScene = scene; if (!scene || scene.v !== 2) return;
return;
}
doApplyScene(scene);
}
function doApplyScene(scene: any) { receiving = true;
_suppress = true; sceneManager
// Ensure all images have crossOrigin set before Fabric loads them, .loadScene(scene)
// otherwise the canvas becomes tainted and clipboard copy breaks.
if (scene?.objects) {
for (const obj of scene.objects) {
if (obj.type === 'image') obj.crossOrigin = 'anonymous';
if (obj.type === 'group' && obj.objects) {
for (const child of obj.objects) {
if (child.type === 'image') child.crossOrigin = 'anonymous';
}
}
}
}
canvas.loadFromJSON(scene)
.then(() => { .then(() => {
canvas.requestRenderAll(); receiving = false;
// Small delay for any deferred Fabric events
setTimeout(() => { _suppress = false; }, 50);
}) })
.catch((err: any) => { .catch((err: any) => {
console.error('[sync] loadFromJSON failed:', err); console.error('[sync] loadScene failed:', err);
_suppress = false; receiving = false;
}); });
} }
// ---- RECEIVE: lightweight transform ---- // ---- RECEIVE: lightweight transform -----------------------------------
function applyRemoteTransform(payload: any) { function onTransformReceived(payload: any) {
if (payload.boardId !== boardId) return; if (payload.boardId !== boardId) return;
const obj = canvas.getObjects().find((o) => (o as any).id === payload.objectId); const item = sceneManager.getById(payload.objectId);
if (!obj) return; if (!item) return;
_suppress = true; // Update data model
obj.set({ item.data.x = payload.x;
left: payload.left, item.data.y = payload.y;
top: payload.top, item.data.sx = payload.sx;
scaleX: payload.scaleX, item.data.sy = payload.sy;
scaleY: payload.scaleY, item.data.angle = payload.angle;
angle: payload.angle,
}); // Update display object
obj.setCoords(); const obj = item.displayObject;
canvas.requestRenderAll(); obj.position.set(payload.x, payload.y);
_suppress = false; obj.scale.set(payload.sx, payload.sy);
obj.angle = payload.angle;
// No onChange — this is a remote update
} }
// ---- Canvas event handlers ---- // ---- Wire up SceneManager onChange → broadcastScene --------------------
function onObjectAdded() { const prevOnChange = sceneManager.onChange;
if (!_suppress) scheduleBroadcast(); sceneManager.onChange = () => {
} prevOnChange?.();
broadcastScene();
};
function onObjectModified() { // ---- Bind socket events -----------------------------------------------
if (!_suppress) scheduleBroadcast();
}
function onObjectRemoved() { socket.on('scene:update', onSceneReceived);
if (!_suppress) scheduleBroadcast(); socket.on('object:transform', onTransformReceived);
}
function onPathCreated() { // ---- Join room --------------------------------------------------------
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) => { socket.emit('board:join', { boardId }, (response: any) => {
if (response?.users) { if (response?.users) {
@@ -201,21 +119,14 @@ export function setupSync(
} }
}); });
// ---- Cleanup ---- // ---- Cleanup ----------------------------------------------------------
return () => { return () => {
canvas.off('object:added', onObjectAdded); // Restore previous onChange
canvas.off('object:modified', onObjectModified); sceneManager.onChange = prevOnChange;
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('scene:update', onSceneReceived);
socket.off('object:transform', applyRemoteTransform); socket.off('object:transform', onTransformReceived);
socket.emit('board:leave', { boardId }); socket.emit('board:leave', { boardId });
@@ -223,3 +134,6 @@ export function setupSync(
if (moveTimer) clearTimeout(moveTimer); if (moveTimer) clearTimeout(moveTimer);
}; };
} }
// Note: broadcastTransform for drag events will be wired via Editor integration.
// SelectionManager / TransformBox will call it directly once connected.