fix(refboard): full sync audit — batch multi-drag, persist resize, sync lock

- Batch broadcastTransform: sends all selected items in one emit instead
  of throttle-dropping all but the first (multi-select drag now syncs all)
- TransformBox onDragEnd: persist+sync final state after resize/rotate
  (was only broadcasting ephemeral transforms, never the final position)
- Layer lock toggle: add missing onCanvasChange() call for sync+save
- Receiver handles both batched and legacy single-item transform formats
This commit is contained in:
Hiren Kangad
2026-03-10 04:16:14 +05:30
parent f0f124d1d2
commit cebe4a3eb9
5 changed files with 64 additions and 29 deletions
+12 -2
View File
@@ -40,6 +40,7 @@ export class SelectionManager {
private _bandGfx: Graphics; private _bandGfx: Graphics;
private _onSelectionChange: ((ids: string[]) => void) | null = null; private _onSelectionChange: ((ids: string[]) => void) | null = null;
private _onItemTransform: ((item: SceneItem) => void) | null = null; private _onItemTransform: ((item: SceneItem) => void) | null = null;
private _onItemsTransform: ((items: SceneItem[]) => void) | null = null;
// Pointer state // Pointer state
private _pointerDown = false; private _pointerDown = false;
@@ -116,6 +117,11 @@ export class SelectionManager {
this._onItemTransform = fn; this._onItemTransform = fn;
} }
/** Called during drag with ALL moved items for batched sync broadcast. */
set onItemsTransform(fn: (items: SceneItem[]) => void) {
this._onItemsTransform = fn;
}
/** Called on double-click of a text item (for inline editing). */ /** Called on double-click of a text item (for inline editing). */
set onDoubleClickText(fn: (item: SceneItem) => void) { set onDoubleClickText(fn: (item: SceneItem) => void) {
this._onDoubleClickText = fn; this._onDoubleClickText = fn;
@@ -260,13 +266,17 @@ export class SelectionManager {
ddy += snap.dy; ddy += snap.dy;
this._snapGuides.drawGuides(snap.guides, this._viewport); this._snapGuides.drawGuides(snap.guides, this._viewport);
// Move all selected items by corrected delta and broadcast transforms // Move all selected items by corrected delta and broadcast all together
for (const item of selected) { for (const item of selected) {
item.displayObject.x += ddx; item.displayObject.x += ddx;
item.displayObject.y += ddy; item.displayObject.y += ddy;
item.data.x = item.displayObject.x; item.data.x = item.displayObject.x;
item.data.y = item.displayObject.y; item.data.y = item.displayObject.y;
this._onItemTransform?.(item); }
if (this._onItemsTransform) {
this._onItemsTransform(selected);
} else if (this._onItemTransform) {
for (const item of selected) this._onItemTransform(item);
} }
this.transformBox.update(selected); this.transformBox.update(selected);
} }
+14 -4
View File
@@ -58,6 +58,7 @@ export class TransformBox extends Container {
private _drag: DragState | null = null; private _drag: DragState | null = null;
private _viewport: Viewport | null = null; private _viewport: Viewport | null = null;
private _onItemTransform: ((item: SceneItem) => void) | null = null; private _onItemTransform: ((item: SceneItem) => void) | null = null;
private _onDragEnd: (() => void) | null = null;
private _snapGuides: SnapGuides | null = null; private _snapGuides: SnapGuides | null = null;
private _dimLabel!: Text; private _dimLabel!: Text;
private _dimLabelBg!: Graphics; private _dimLabelBg!: Graphics;
@@ -66,6 +67,11 @@ export class TransformBox extends Container {
this._onItemTransform = fn; this._onItemTransform = fn;
} }
/** Called when resize/rotate drag ends — use to persist/sync final state. */
set onDragEnd(fn: () => void) {
this._onDragEnd = fn;
}
setSnapGuides(sg: SnapGuides): void { setSnapGuides(sg: SnapGuides): void {
this._snapGuides = sg; this._snapGuides = sg;
} }
@@ -389,9 +395,13 @@ export class TransformBox extends Container {
} }
private _onHandleUp(): void { private _onHandleUp(): void {
this._drag = null; if (this._drag) {
this._dimLabel.visible = false; this._drag = null;
this._dimLabelBg.visible = false; this._dimLabel.visible = false;
this._snapGuides?.endSession(); this._dimLabelBg.visible = false;
this._snapGuides?.endSession();
// Notify that drag ended — persist/sync the final state
this._onDragEnd?.();
}
} }
} }
+28 -21
View File
@@ -20,7 +20,7 @@ import type { SceneData, AnySceneObject } from './scene-format';
export interface SyncHandle { export interface SyncHandle {
cleanup: () => void; cleanup: () => void;
broadcastTransform: (item: SceneItem) => void; broadcastTransform: (items: SceneItem | SceneItem[]) => void;
/** Force an immediate full scene broadcast (call after structural changes). */ /** Force an immediate full scene broadcast (call after structural changes). */
broadcastSceneNow: () => void; broadcastSceneNow: () => void;
/** Broadcast only specific changed elements (lightweight). */ /** Broadcast only specific changed elements (lightweight). */
@@ -120,19 +120,19 @@ export function setupSync(
// ---- BROADCAST: lightweight transform during drag ------------------------- // ---- BROADCAST: lightweight transform during drag -------------------------
function broadcastTransform(item: SceneItem) { function broadcastTransform(itemOrItems: SceneItem | SceneItem[]) {
if (receiving) return; if (receiving) return;
if (moveTimer) return; if (moveTimer) return;
const { id, data } = item; const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
socket.emit('object:transform', { const transforms = items.map(({ id, data }) => ({
boardId,
objectId: id, objectId: id,
x: data.x, x: data.x,
y: data.y, y: data.y,
sx: data.sx, sx: data.sx,
sy: data.sy, sy: data.sy,
angle: data.angle, angle: data.angle,
}); }));
socket.emit('object:transform', { boardId, transforms });
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE); moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
broadcastSceneDebounced(); broadcastSceneDebounced();
} }
@@ -217,23 +217,30 @@ export function setupSync(
// ---- RECEIVE: lightweight transform --------------------------------------- // ---- 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;
const obj = item.displayObject;
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) { function onTransformReceived(payload: any) {
if (payload.boardId !== boardId) return; if (payload.boardId !== boardId) return;
const item = sceneManager.getById(payload.objectId); // Batched format: { transforms: [...] }
if (!item) return; if (Array.isArray(payload.transforms)) {
for (const t of payload.transforms) applyTransform(t);
item.data.x = payload.x; } else if (payload.objectId) {
item.data.y = payload.y; // Legacy single-item format
item.data.sx = payload.sx; applyTransform(payload);
item.data.sy = payload.sy; }
item.data.angle = payload.angle;
const obj = item.displayObject;
obj.position.set(payload.x, payload.y);
obj.scale.set(payload.sx, payload.sy);
obj.angle = payload.angle;
options?.onRemoteTransform?.(item);
} }
// ---- Wire up SceneManager onChange → debounced full sync ------------------- // ---- Wire up SceneManager onChange → debounced full sync -------------------
+8 -1
View File
@@ -126,13 +126,20 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
}, },
}); });
// Wire live drag/resize transforms to sync broadcast // Wire live drag/resize transforms to sync broadcast (batched for multi-select)
selection.onItemsTransform = (items) => {
syncRef.current?.broadcastTransform(items);
};
selection.onItemTransform = (item) => { selection.onItemTransform = (item) => {
syncRef.current?.broadcastTransform(item); syncRef.current?.broadcastTransform(item);
}; };
selection.transformBox.onItemTransform = (item) => { selection.transformBox.onItemTransform = (item) => {
syncRef.current?.broadcastTransform(item); syncRef.current?.broadcastTransform(item);
}; };
selection.transformBox.onDragEnd = () => {
syncRef.current?.broadcastSceneNow();
onCanvasChange();
};
socket.on('user:joined', (data: any) => { socket.on('user:joined', (data: any) => {
const uid = data.userId || data.id; const uid = data.userId || data.id;
+2 -1
View File
@@ -107,8 +107,9 @@ export function useLayerPanel(deps: LayerPanelDeps) {
if (item) { if (item) {
item.data.locked = !item.data.locked; item.data.locked = !item.data.locked;
item.displayObject.eventMode = item.data.locked ? 'none' : 'static'; item.displayObject.eventMode = item.data.locked ? 'none' : 'static';
onCanvasChange();
} }
}, [canvasRef]); }, [canvasRef, onCanvasChange]);
const onReorder = useCallback((from: number, to: number) => { const onReorder = useCallback((from: number, to: number) => {
const scene = canvasRef.current?.getScene(); const scene = canvasRef.current?.getScene();