From 21ee4b2601fd437de9456a5a9548848cff5b81bc Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 10 Mar 2026 10:59:04 +0530 Subject: [PATCH] =?UTF-8?q?feat(refboard):=20incremental=20sync=20?= =?UTF-8?q?=E2=80=94=20stop=20full-scene=20broadcast=20on=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/canvas/TransformBox.ts | 6 +-- frontend/src/canvas/context-menu-items.ts | 39 +++++++++--------- frontend/src/canvas/shortcut-definitions.ts | 4 +- frontend/src/canvas/shortcuts.ts | 2 +- frontend/src/hooks/useCanvasSetup.ts | 8 ++-- frontend/src/hooks/useLayerPanel.ts | 2 +- frontend/src/hooks/useShortcutHandler.ts | 2 +- frontend/src/pages/Editor.tsx | 45 ++++++++++++--------- 8 files changed, 57 insertions(+), 51 deletions(-) diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index 8473004..468fec1 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -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)); } } } diff --git a/frontend/src/canvas/context-menu-items.ts b/frontend/src/canvas/context-menu-items.ts index e424ed6..85237d0 100644 --- a/frontend/src/canvas/context-menu-items.ts +++ b/frontend/src/canvas/context-menu-items.ts @@ -25,7 +25,7 @@ interface MenuContext { viewport: Viewport | null; clipboardRef: React.MutableRefObject; writeCanvasToClipboard: (items?: SceneItem[]) => Promise; - 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]); } diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index 2b5d7b4..3dd047e 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -102,12 +102,12 @@ async function _pasteInternal(ctx: ShortcutContext): Promise { 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[] = [ diff --git a/frontend/src/canvas/shortcuts.ts b/frontend/src/canvas/shortcuts.ts index 1edbc9b..c0e9b5b 100644 --- a/frontend/src/canvas/shortcuts.ts +++ b/frontend/src/canvas/shortcuts.ts @@ -31,7 +31,7 @@ export interface ShortcutContext { selection: SelectionManager; history: UndoManager; viewport: Viewport; - onChange: () => void; + onChange: (changedIds?: string[]) => void; clipboardRef: React.MutableRefObject; showToast: (msg: string) => void; fitAll: () => void; diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index ac13530..3c2f943 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -40,7 +40,7 @@ interface CanvasSetupDeps { undoRef: React.MutableRefObject; syncRef: React.MutableRefObject; inboxZoneRef: React.MutableRefObject; - onCanvasChange: () => void; + onCanvasChange: (changedIds?: string[]) => void; showToast: (msg: string) => void; setOnlineUsers: React.Dispatch>; setSelectedLayerIds: React.Dispatch>; @@ -136,9 +136,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { selection.transformBox.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); }; - selection.transformBox.onDragEnd = () => { - syncRef.current?.broadcastSceneNow(); - onCanvasChange(); + selection.transformBox.onDragEnd = (itemIds) => { + syncRef.current?.broadcastElements(itemIds); + onCanvasChange(itemIds); }; socket.on('user:joined', (data: any) => { diff --git a/frontend/src/hooks/useLayerPanel.ts b/frontend/src/hooks/useLayerPanel.ts index 15c9d19..562c586 100644 --- a/frontend/src/hooks/useLayerPanel.ts +++ b/frontend/src/hooks/useLayerPanel.ts @@ -17,7 +17,7 @@ interface LayerItem { interface LayerPanelDeps { canvasRef: React.RefObject; selectionRef: React.RefObject; - onCanvasChange: () => void; + onCanvasChange: (changedIds?: string[]) => void; } function autoName(item: SceneItem, i: number, counters: Record): string { diff --git a/frontend/src/hooks/useShortcutHandler.ts b/frontend/src/hooks/useShortcutHandler.ts index a1a4213..8e0d4a3 100644 --- a/frontend/src/hooks/useShortcutHandler.ts +++ b/frontend/src/hooks/useShortcutHandler.ts @@ -18,7 +18,7 @@ interface ShortcutHandlerDeps { undoRef: React.RefObject; clipboardRef: React.MutableRefObject; resolvedBoardId: string | undefined; - onCanvasChange: () => void; + onCanvasChange: (changedIds?: string[]) => void; showToast: (msg: string) => void; refreshLayers: () => void; handleGroup: () => void; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index e4601a4..99e8083 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -102,11 +102,16 @@ export default function Editor({ isPublicView }: EditorProps) { // Save manager const { scheduleSave } = useSaveManager({ resolvedBoardId, isPublicView, canvasRef, setSaveStatus }); - // Canvas change handler - const onCanvasChange = useCallback(() => { + // Canvas change handler. + // Pass changedIds for incremental sync (fast, lightweight). + // Omit for structural changes — falls through to debounced full scene sync. + const onCanvasChange = useCallback((changedIds?: string[]) => { scheduleSave(); - // Broadcast changes to other connected clients immediately - syncRef.current?.broadcastSceneNow(); + if (changedIds && changedIds.length > 0) { + // Incremental: broadcast only changed elements + syncRef.current?.broadcastElements(changedIds); + } + // Full scene sync happens via debounced SceneManager.onChange chain in sync.ts if (undoRef.current && !undoRef.current.isLocked()) { undoRef.current.saveState(); setCanUndo(undoRef.current.canUndo()); @@ -528,23 +533,23 @@ export default function Editor({ isPublicView }: EditorProps) { x={selToolbar.x} y={selToolbar.y} count={selToolbar.count} - onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} - onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} + onAlignLeft={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignLeft(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onAlignCenterH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterH(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onAlignRight={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignRight(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onAlignTop={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignTop(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onAlignCenterV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignCenterV(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onAlignBottom={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.alignBottom(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onDistributeH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onDistributeV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.distributeVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onPack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeOptimal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onGrid={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeGrid(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onRow={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeRow(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onColumn={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.arrangeColumn(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onStack={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.stackObjects(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onFlipH={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipHorizontal(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} + onFlipV={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.flipVertical(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} onGroup={handleGroup} - onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); selectionRef.current?.transformBox.update(s); onCanvasChange(); } }} + onNormSize={() => { const s = selectionRef.current?.getSelectedItems(); if (s) { ops.normalizeSize(s); selectionRef.current?.transformBox.update(s); onCanvasChange(s.map(i => i.id)); } }} /> )}