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