Bring to Front / Send to Back z-order changes don't sync to other open windows on the same board #208
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#208
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
When one user reorders an object on the canvas with Bring to Front or Send to Back (from the right-click context menu or the floating toolbar), the change is visible locally but the other browser windows / tabs viewing the same board don't update their stacking. Refreshing the other window picks up the new order (so the server persists it), but live sync misses the update.
The same goes for the Move Forward / Move Backward stepwise variants in the toolbar (
_commitZOrdercovers all four).Reproduction
Same behaviour with Send to Back, Move Forward, and Move Backward.
Where things look correct on paper
WhiteboardSync.onUpdate(node):crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js_commitZOrder(line ~1004) aftermoveToTop/moveToBottom/moveUp/moveDown.crates/hero_whiteboard_admin/static/web/js/whiteboard/contextmenu.jsbringToFront/sendToBack(lines ~216/~230) aftermoveToTop/moveToBottom.crates/hero_whiteboard_admin/static/web/js/whiteboard/sync.jsserializeForServer(line ~266) includesz_index: node.zIndex() || 0(line ~405).flushUpdatessendsobject.updateover JSON-RPC withz_index(lines ~498/511) and broadcastsobject.updatedover WebSocket (lines ~506/522).object.updatehandler (crates/hero_whiteboard_server/src/handlers/object.rsline ~113) writesz_indexinto the row.applySyncUpdate(sync.js ~line 587) updatesnode.zIndex(obj.z_index)(lines ~655-657) and schedules a layerbatchDrawvia rAF (line ~899-904).Likely culprits to investigate during the spec phase
applySyncUpdatefor theseobject.updatedmessages — check the dispatcher aroundsync.js:160and any guard like "skip if sender is me" that might match.markBroadcastmachinery (H8) is filtering the update on either side.node.zIndex(N)is being called insideapplySyncUpdatefor the right node when the node is nested inside a parent frame group rather than a top-level child of the object layer; the indexNis then relative to the frame's children, not the object layer.obj.z_index !== node.zIndex()guard at sync.js:655 short-circuits when the remote's runtime zIndex coincidentally matches the broadcast value (e.g. multiple frames of children, repeated 0s).z_indexfield somehow.node.zIndex(N)here actually triggers the reorder visually after the rAFbatchDraw.Scope
Make live z-order changes propagate to all open windows on the same board within the existing sync debounce / throttle window, for:
Apply the same code path uniformly — no new RPC needed (object.update already supports
z_index).Requirements
WhiteboardWebframe.refreshAllLayering) recomputes on the receiver after the remote's stacking update so the iframe overlay shows/hides as expected on the other window too.Acceptance Criteria
Notes
The whiteboard frontend is vanilla JS modules under
crates/hero_whiteboard_admin/static/web/js/whiteboard/embedded via rust-embed; rebuild the admin crate to pick up JS changes. Server-side z_index persistence already works (the SQL UPDATE incrates/hero_whiteboard_server/src/db/queries.rsincludesz_index). The fix is most likely a small change in the sync send or receive path.Implementation Spec for Issue #208
Objective
Make Bring to Front / Send to Back / Move Forward / Move Backward propagate the new stacking order to every other open window on the same board in real time, instead of only after a reload. Fix must work in both single-node (context menu) and multi-node (selection toolbar) paths.
Root cause
The Konva Transformer lives on the same layer as real object groups (
tools.js:112adds it as a sibling), and both bringToFront paths move the transformer above the moved object (tools.js:1018,contextmenu.js:222).serializeForServeratsync.js:405then uses the raw layer-child index:z_index: node.zIndex() || 0.Concrete trace: board with three objects + transformer, layer children
[T, A, B, C]. Sender doesbringToFront(A):A.moveToTop()→[T, B, C, A],A.zIndex() == 3.transformer.moveToTop()→[B, C, A, T],A.zIndex() == 2.z_index: 2for A. Receiver layer is still[T, A, B, C].applySyncUpdate(sync.js:655-657) callsnode.zIndex(2)→[T, B, A, C]. C still above A — the broadcast value is meaningless across windows because each window's transformer occupies a different slot.Additionally, only the moved node's
z_indexis persisted; displaced objects keep stale DB values, so reload (ORDER BY z_index ASCinqueries.rs:366) returns inconsistent order when ranks collide.Other parts of the pipeline are healthy: server-side
object.updatewritesz_index(object.rs:113); WS relay forwards JSON verbatim;handleWsMessagedoesn't filter peers; H8/markBroadcast doesn't block remote application.Requirements
z_indexremains monotonic per board solist_objectsreturns objects in the intended order after reload.Files to Modify/Create
crates/hero_whiteboard_admin/static/web/js/whiteboard/sync.js— change wirez_indexsemantics from "layer-child index" to "rank among tracked objects" on both serialize and apply.crates/hero_whiteboard_admin/static/web/js/whiteboard/contextmenu.js— after bringToFront/sendToBack, fan outonUpdateto all tracked objects (debounce+batch coalesces).crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js— same fan-out at the end of_commitZOrderfor the multi-node toolbar path.No new files.
Implementation Plan
Step 1: Rank helpers in sync.js
Files:
sync.jsobjectRankOf(node): walksnode.getParent().children, returns the count of children withzIndex < node.zIndex()that are present inWhiteboardObjects.getAllObjects()(skips Transformer, selectionRect, drawing preview — none are in that map).setObjectRank(node, rank): iterateparent.children, count tracked-object siblings excludingnode; choose the absolute child index that putsnodeat the requested rank among tracked objects; callnode.zIndex(absoluteIndex). Clamp rank to[0, trackedCount-1].Dependencies: none
Step 2: Serialize uses rank (depends on Step 1)
Files:
sync.jssync.js:405z_index: node.zIndex() || 0,withz_index: objectRankOf(node),. This affects all outbound writes (onCreate,flushUpdatessingle + batch,persistNow,saveAll).Step 3: Apply uses rank (depends on Step 1; parallelizable with Step 2)
Files:
sync.jssync.js:655-657: with:!== node.zIndex()short-circuit (apples to oranges —setObjectRankis idempotent so an unnecessary call is a no-op).Step 4: Fan-out in contextmenu.js (depends on Steps 2+3; parallelizable with Step 5)
Files:
contextmenu.jsbringToFront(line ~216) andsendToBack(line ~230), after the localmoveToTop()/moveToBottom()+batchDraw(), replace the singleWhiteboardSync.onUpdate(targetNode)call with a loop:object.batch_updatecoalesce this into one round-trip; serializeForServer for unchanged nodes emits identical geometry so it's idempotent.Step 5: Fan-out in tools.js (depends on Steps 2+3; parallelizable with Step 4)
Files:
tools.js_commitZOrder(line ~1004), after the existing per-nodeWhiteboardSync.onUpdate(n)loop, append the same fan-out so displaced (unmoved) tracked objects also persist their new ranks.Step 6: Build + manual verify
touch crates/hero_whiteboard_admin/src/assets.rs && cargo build --release -p hero_whiteboard_admin.Acceptance Criteria
z_indexranks are now monotonic).Notes
static/web/js/whiteboard/. No Rust changes.z_indexsemantics change from "layer-child index" to "rank among tracked objects".list_objectsalready orders ASC, so existing rows continue to load in correct relative order.wsSendThrottledand the RPC layer batches into oneobject.batch_updateper z-order action.Implementation Summary
Live z-order changes (Bring to Front / Send to Back / Move Forward / Move Backward) now propagate to other open windows on the same board.
Root cause
The Konva Transformer is a sibling of real objects on the object layer.
serializeForServerused the raw Konva child index (node.zIndex() || 0), which mixed the transformer's slot into the value. Two windows whose transformers sit in different slots therefore disagreed on what an index meant. On top of that, only the moved node'sz_indexwas persisted; displaced objects kept stale DB values, so reload order could collide.Changes
crates/hero_whiteboard_admin/static/web/js/whiteboard/sync.js:objectRankOf(node)— rank of a node among its tracked-object siblings (the Transformer, marquee selection rect, and drawing-preview lines are not inWhiteboardObjects.getAllObjects()and are skipped).setObjectRank(node, rank)— inverse: moves a node so it becomes the rank-th tracked-object sibling, clamp-safe, idempotent.z_index: objectRankOf(node); apply now callssetObjectRank(node, obj.z_index).crates/hero_whiteboard_admin/static/web/js/whiteboard/contextmenu.js: afterbringToFront/sendToBackdo their localmoveToTop/moveToBottom, theonUpdatecall fans out to every tracked object so all displaced ranks persist (the existing 500ms debounce +object.batch_updatecoalesces them into one round-trip).crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js: same fan-out in_commitZOrder(covers the multi-node toolbar path: bringToFront, sendToBack, moveForward, moveBackward).Behavior
z_indexranks remain monotonic per board; reload renders the same order.Test results
cargo test --workspace --lib: compiled cleanly, no failures (change is JS-only; regression guard).node --checkpassed forsync.js,contextmenu.js,tools.js. File encodings clean.sync.jscontains the newobjectRankOf/setObjectRankhelpers and the updated serialize/apply sites.Notes
z_indexsemantics change from "raw child index in the object layer" to "rank among tracked objects".list_objectsalready orders ASC, so existing rows continue to load in the correct relative order.