From cebe4a3eb9b6a87392009cacefc6e21bdb16c39b Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 10 Mar 2026 04:16:14 +0530 Subject: [PATCH] =?UTF-8?q?fix(refboard):=20full=20sync=20audit=20?= =?UTF-8?q?=E2=80=94=20batch=20multi-drag,=20persist=20resize,=20sync=20lo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/canvas/SelectionManager.ts | 14 ++++++- frontend/src/canvas/TransformBox.ts | 18 +++++++-- frontend/src/canvas/sync.ts | 49 ++++++++++++++----------- frontend/src/hooks/useCanvasSetup.ts | 9 ++++- frontend/src/hooks/useLayerPanel.ts | 3 +- 5 files changed, 64 insertions(+), 29 deletions(-) diff --git a/frontend/src/canvas/SelectionManager.ts b/frontend/src/canvas/SelectionManager.ts index 9360eb4..dc6366c 100644 --- a/frontend/src/canvas/SelectionManager.ts +++ b/frontend/src/canvas/SelectionManager.ts @@ -40,6 +40,7 @@ export class SelectionManager { private _bandGfx: Graphics; private _onSelectionChange: ((ids: string[]) => void) | null = null; private _onItemTransform: ((item: SceneItem) => void) | null = null; + private _onItemsTransform: ((items: SceneItem[]) => void) | null = null; // Pointer state private _pointerDown = false; @@ -116,6 +117,11 @@ export class SelectionManager { 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). */ set onDoubleClickText(fn: (item: SceneItem) => void) { this._onDoubleClickText = fn; @@ -260,13 +266,17 @@ export class SelectionManager { ddy += snap.dy; 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) { item.displayObject.x += ddx; item.displayObject.y += ddy; item.data.x = item.displayObject.x; 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); } diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index c459d86..8473004 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -58,6 +58,7 @@ export class TransformBox extends Container { private _drag: DragState | null = null; private _viewport: Viewport | null = null; private _onItemTransform: ((item: SceneItem) => void) | null = null; + private _onDragEnd: (() => void) | null = null; private _snapGuides: SnapGuides | null = null; private _dimLabel!: Text; private _dimLabelBg!: Graphics; @@ -66,6 +67,11 @@ export class TransformBox extends Container { 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 { this._snapGuides = sg; } @@ -389,9 +395,13 @@ export class TransformBox extends Container { } private _onHandleUp(): void { - this._drag = null; - this._dimLabel.visible = false; - this._dimLabelBg.visible = false; - this._snapGuides?.endSession(); + if (this._drag) { + this._drag = null; + this._dimLabel.visible = false; + this._dimLabelBg.visible = false; + this._snapGuides?.endSession(); + // Notify that drag ended — persist/sync the final state + this._onDragEnd?.(); + } } } diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts index 03f3745..9ccd201 100644 --- a/frontend/src/canvas/sync.ts +++ b/frontend/src/canvas/sync.ts @@ -20,7 +20,7 @@ import type { SceneData, AnySceneObject } from './scene-format'; export interface SyncHandle { cleanup: () => void; - broadcastTransform: (item: SceneItem) => void; + broadcastTransform: (items: SceneItem | SceneItem[]) => void; /** Force an immediate full scene broadcast (call after structural changes). */ broadcastSceneNow: () => void; /** Broadcast only specific changed elements (lightweight). */ @@ -120,19 +120,19 @@ export function setupSync( // ---- BROADCAST: lightweight transform during drag ------------------------- - function broadcastTransform(item: SceneItem) { + function broadcastTransform(itemOrItems: SceneItem | SceneItem[]) { if (receiving) return; if (moveTimer) return; - const { id, data } = item; - socket.emit('object:transform', { - boardId, + 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, - }); + })); + socket.emit('object:transform', { boardId, transforms }); moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE); broadcastSceneDebounced(); } @@ -217,23 +217,30 @@ export function setupSync( // ---- 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) { if (payload.boardId !== boardId) return; - const item = sceneManager.getById(payload.objectId); - if (!item) return; - - 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; - - 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); + // 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 ------------------- diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index e723b8d..ac13530 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -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) => { syncRef.current?.broadcastTransform(item); }; selection.transformBox.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); }; + selection.transformBox.onDragEnd = () => { + syncRef.current?.broadcastSceneNow(); + onCanvasChange(); + }; socket.on('user:joined', (data: any) => { const uid = data.userId || data.id; diff --git a/frontend/src/hooks/useLayerPanel.ts b/frontend/src/hooks/useLayerPanel.ts index 48bca56..15c9d19 100644 --- a/frontend/src/hooks/useLayerPanel.ts +++ b/frontend/src/hooks/useLayerPanel.ts @@ -107,8 +107,9 @@ export function useLayerPanel(deps: LayerPanelDeps) { if (item) { item.data.locked = !item.data.locked; item.displayObject.eventMode = item.data.locked ? 'none' : 'static'; + onCanvasChange(); } - }, [canvasRef]); + }, [canvasRef, onCanvasChange]); const onReorder = useCallback((from: number, to: number) => { const scene = canvasRef.current?.getScene();