feat(refboard): incremental sync — stop full-scene broadcast on edits

Replace broadcastSceneNow() with incremental element:update for all
property-modifying operations (align, arrange, flip, resize, rotate,
normalize, frame color). Full scene sync now only fires via debounced
fallback (500ms) for structural changes (add/remove/paste/group).

Changes:
- onCanvasChange() accepts optional changedIds for incremental sync
- SelectionToolbar, context menu, shortcuts all pass item IDs
- TransformBox.onDragEnd passes item IDs instead of full scene
- ShortcutContext, MenuContext, hook interfaces updated

This dramatically reduces sync traffic during normal editing — only
the changed elements are sent instead of the entire board state.
This commit is contained in:
Hiren Kangad
2026-03-10 10:59:04 +05:30
parent 86024464c2
commit 21ee4b2601
8 changed files with 57 additions and 51 deletions
+3 -3
View File
@@ -58,7 +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 _onDragEnd: ((itemIds: string[]) => void) | null = null;
private _snapGuides: SnapGuides | null = null;
private _dimLabel!: Text;
private _dimLabelBg!: Graphics;
@@ -68,7 +68,7 @@ export class TransformBox extends Container {
}
/** Called when resize/rotate drag ends — use to persist/sync final state. */
set onDragEnd(fn: () => void) {
set onDragEnd(fn: (itemIds: string[]) => void) {
this._onDragEnd = fn;
}
@@ -401,7 +401,7 @@ export class TransformBox extends Container {
this._dimLabelBg.visible = false;
this._snapGuides?.endSession();
// Notify that drag ended — persist/sync the final state
this._onDragEnd?.();
this._onDragEnd?.(this._items.map(i => i.id));
}
}
}
+20 -19
View File
@@ -25,7 +25,7 @@ interface MenuContext {
viewport: Viewport | null;
clipboardRef: React.MutableRefObject<SceneItem[]>;
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
onChange: () => void;
onChange: (changedIds?: string[]) => void;
refreshLayers: () => void;
handleGroup: () => void;
handleUngroup: () => void;
@@ -37,6 +37,7 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
const selected = selection ? selection.getSelectedItems() : [];
const hasSel = selected.length > 0;
const multiSel = selected.length >= 2;
const ids = selected.map((s) => s.id);
return [
// -- Clipboard --
@@ -78,12 +79,12 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Alignment --
{ label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => { ops.alignLeft(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => { ops.alignRight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => { ops.alignTop(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => { ops.alignBottom(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Distribute H', shortcut: '', onClick: () => { ops.distributeHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Distribute V', shortcut: '', onClick: () => { ops.distributeVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => { ops.alignLeft(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => { ops.alignRight(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => { ops.alignTop(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => { ops.alignBottom(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Distribute H', shortcut: '', onClick: () => { ops.distributeHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Distribute V', shortcut: '', onClick: () => { ops.distributeVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Layer ordering --
@@ -134,23 +135,23 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Arrangement --
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Normalize --
{ label: 'Normalize Size', shortcut: '', onClick: () => { ops.normalizeSize(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Normalize Width', shortcut: '', onClick: () => { ops.normalizeWidth(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Normalize Height', shortcut: '', onClick: () => { ops.normalizeHeight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
{ label: 'Normalize Size', shortcut: '', onClick: () => { ops.normalizeSize(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Normalize Width', shortcut: '', onClick: () => { ops.normalizeWidth(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: 'Normalize Height', shortcut: '', onClick: () => { ops.normalizeHeight(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Image --
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- View --
@@ -244,5 +245,5 @@ function _setFrameColor(item: SceneItem, color: string, ctx: MenuContext): void
if (item.displayObject instanceof FrameSprite) {
item.displayObject.setBgColor(color);
}
ctx.onChange();
ctx.onChange([item.id]);
}
+2 -2
View File
@@ -102,12 +102,12 @@ async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
ctx.onChange();
}
/** Run op on selected items → update transform box → fire onChange. */
/** Run op on selected items → update transform box → fire onChange with IDs. */
function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
const items = ctx.selection.getSelectedItems();
op(items);
ctx.selection.transformBox.update(items);
ctx.onChange();
ctx.onChange(items.map(i => i.id));
}
export const shortcuts: ShortcutDef[] = [
+1 -1
View File
@@ -31,7 +31,7 @@ export interface ShortcutContext {
selection: SelectionManager;
history: UndoManager;
viewport: Viewport;
onChange: () => void;
onChange: (changedIds?: string[]) => void;
clipboardRef: React.MutableRefObject<SceneItem[]>;
showToast: (msg: string) => void;
fitAll: () => void;