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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (this._drag) {
|
||||||
this._drag = null;
|
this._drag = null;
|
||||||
this._dimLabel.visible = false;
|
this._dimLabel.visible = false;
|
||||||
this._dimLabelBg.visible = false;
|
this._dimLabelBg.visible = false;
|
||||||
this._snapGuides?.endSession();
|
this._snapGuides?.endSession();
|
||||||
|
// Notify that drag ended — persist/sync the final state
|
||||||
|
this._onDragEnd?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-21
@@ -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 -------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user