Frame: only contain explicitly-placed children, not anything that overlaps #96
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#96
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?
Problem
Issue #89 made the frame act as a drag-with-children container. The capture rule is geometric: at
dragstart, every other top-level object whose bounding box is fully inside the frame's bounding box is captured and moves with the frame.That rule is wrong for the common case where the user drops a NEW frame on top of EXISTING content. The frame happens to overlap a sticky note that was placed there earlier and that the user did not intend to be "inside" the frame. Dragging the frame yanks the unrelated sticky along with it.
Frames should only contain objects that are explicitly placed or moved inside them.
Reproduction
Expected behavior
parent_frame_id(or null). The frame's drag-with-children path captures only objects whoseparent_frame_idmatches the dragged frame's id -- not objects whose bounding box happens to be inside the frame's bounding box.parent_frame_idis set to a frame's id when:parent_frame_idis cleared when:parent_frame_id.Affected files (expected)
crates/hero_whiteboard_server/src/db/queries.rs+ relevant migration -- addparent_frame_idto theobjectstable (nullable foreign key toobjects.id, cascade-on-delete: SET NULL).crates/hero_whiteboard_server/src/handlers/object.rs+db/models.rs-- round-trip the new field.crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js-- includeparent_frame_idin the serialize/apply paths.crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js::createFrame-- replace the geometricdragstartcapture with a query for objects whoseparent_frame_id === frame.id().crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js-- ondragendof any non-frame object, set / clearparent_frame_idbased on whether the drop position is inside a frame; on object creation while a frame is selected and the position is inside its bounds, setparent_frame_idto that frame.Acceptance criteria
parent_frame_idto that frame; subsequent frame drag moves the new object with it.parent_frame_id. Frame drag now moves it.parent_frame_id. Frame drag no longer moves it.parent_frame_idbecomes null).parent_frame_idacross windows.cargo check / clippy / fmt --check / testclean.Notes
objects; existing rows default to NULL (no parent). The geometric capture rule from #89 is removed -- backwards compatibility for boards that relied on the old behavior is intentional, since that behavior was the bug.dragendshould use the same "fully contained" check as #89 (for ergonomic reasons -- a half-overlapping object isn't claimed).parent_frame_idupdates to B's id.Spec — Issue #96: Frame contains only explicitly-placed children
Objective
Replace the geometric "everything inside the frame's bounds at dragstart" capture rule introduced in issue #89 with explicit, persisted parent-child membership. Each non-frame object stores a nullable
parent_frame_idthat points to its containing frame. Frame drag picks up children by id, not by visual overlap, so dropping a new frame onto unrelated content does not capture it.Requirements
objects.parent_frame_id INTEGER NULL,FOREIGN KEY(parent_frame_id) REFERENCES objects(id) ON DELETE SET NULL. Not stored inside the JSONdatablob.parent_frame_idis set on object creation if the new object is fully inside a frame's bounds.parent_frame_idis set/cleared on a non-frame object'sdragendbased on whether the drop position is fully inside any frame's bounds.{o : o.parent_frame_id === frame.id()}— no geometric scan.object.createandobject.update(single-object branch influshUpdates). Thebatch_updatepath does not need to carry membership — membership transitions only fire on a single non-framedragend.object.updatedpayloads must round-trip the new field viaserializeForServerandapplySyncUpdate.ON DELETE SET NULLclears the column server-side; the client receiving anobject.deletedfor a frame must clear localparent_frame_idon any children so a subsequent frame drag elsewhere doesn't try to drag a stale child id._fromSyncopt-out pattern when calling local helpers from the sync receiver.Files to Modify
Server (Rust)
crates/hero_whiteboard_server/src/migrations/007_object_parent_frame.sql— new migration file.crates/hero_whiteboard_server/src/db/mod.rs— register migration 007 inmigrations().crates/hero_whiteboard_server/src/db/models.rs— addparent_frame_id: Option<u64>toBoardObject.crates/hero_whiteboard_server/src/db/queries.rs— extendcreate_object,get_object,list_objects,row_to_object, andupdate_objectto read/write the new column.partial_update_objectdoes NOT need it (batch path).crates/hero_whiteboard_server/src/handlers/object.rs— acceptparent_frame_id(withnull-clear semantics) increateandupdate; serialised return value already covers it viaBoardObjectderive.crates/hero_whiteboard_server/openrpc.json— addparent_frame_idtoBoardObjectschema and to theobject.createandobject.updateparam lists.crates/hero_whiteboard_ui/static/openrpc.json— keep in sync with the server's openrpc.json.SDK
crates/hero_whiteboard_sdk/src/lib.rs— leave alone. The SDK is a hand-written generic JSON-RPC client that forwardsserde_json::Value; it does not pin any object field, so the new field passes through transparently.Frontend (vanilla JS)
crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js:serializeForServer— emitparent_frame_idfrom the local registry into the RPC payload.flushUpdatesbranch — passparent_frame_idin theobject.updatecall alongsidex,y,data. Batch branch left unchanged.applySyncUpdate— write the incomingobj.parent_frame_idback onto the localobjects[id]registry entry so the next local drag uses the synced value.object.deletedhandler — when the deleted object is a frame, walk the local registry and clearparent_frame_idon every child whose value matches the deleted id (defence in depth on top of the FK).crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js:recomputeParentFrame(node)that usesnode.getClientRect({ skipShadow: true }), walksWhiteboardCanvas.getObjectLayer().find('.frame')in reverse z-order, and picks the topmost frame whose client rect fully contains the dropped object. Skips when the node itself is a frame. Returns true ifparent_frame_idchanged.createFramedragstart— replace the existing "fully contained" loop with a registry lookup: capture nodes whoseobjects[node.id()].parent_frame_id === Number(group.id()). Snapshot start positions and callWhiteboardHistory.snapshotBeforeexactly as today.dragend(sticky, text, shape, document, image, emoji, drawing, plus external types) — after the existingWhiteboardSync.onUpdate(group), callif (recomputeParentFrame(group)) { WhiteboardSync.onUpdate(group); }. The duplicateonUpdateis harmless:pendingUpdatesis keyed by id andflushUpdatesis debounced.opts.parent_frame_id; when constructing the registry entry includeparent_frame_id: opts.parent_frame_id != null ? Number(opts.parent_frame_id) : null. For local creation (noopts.id), callrecomputeParentFrame(group)after the entry is written but beforeWhiteboardSync.onCreate(group)so a new object placed inside a frame inherits its membership.createFrameitself never setsparent_frame_idon itself.crates/hero_whiteboard_ui/static/web/js/whiteboard/app.js:createObjectFromData— passparent_frame_id: obj.parent_frame_idinto eachWhiteboardObjects.create*and external create call so the field round-trips on full board load.Files to Leave Alone
crates/hero_whiteboard_ui/static/web/js/whiteboard/connectors.js,comments.js,frames.js(helper, not the entry point),webframe.js,kanban.js,mindmap.js,calendar.js— touched only if theircreate*accepts an extra optionalparent_frame_idand stores it. Audit during implementation; do not preemptively rewrite their drag logic. Theirdragendhandlers can call the sharedrecomputeParentFramehelper.crates/hero_whiteboard_server/src/db/queries.rs::partial_update_object— batch_update path, not a membership transition point.hero_whiteboard_sdk— generic forwarder.Step-by-Step Plan
The two halves (server, frontend) are mostly independent. The only hard sequencing rule is that frontend code calling
object.updatewithparent_frame_idshould not ship until the server accepts the field — but as long as both land in the same release, the order inside the diff doesn't matter.Step 1 — DB migration
File:
crates/hero_whiteboard_server/src/migrations/007_object_parent_frame.sql(new).Body:
File:
crates/hero_whiteboard_server/src/db/mod.rs— appendM::up(include_str!("../migrations/007_object_parent_frame.sql"))toMigrations::new(vec![…]).SQLite supports
ALTER TABLE … ADD COLUMNwith aREFERENCES … ON DELETE SET NULLtable constraint when the column is nullable, which is our case. Existing rows getNULL.Dependencies: none.
Step 2 — Rust model + queries (depends on Step 1)
crates/hero_whiteboard_server/src/db/models.rs: addpub parent_frame_id: Option<u64>toBoardObject(next toparent_id).crates/hero_whiteboard_server/src/db/queries.rs:row_to_object: add the new column read.get_object,list_objects,create_object,update_object: addparent_frame_idcolumn + bind. Bumpparams!and placeholder counts.partial_update_object: untouched.crates/hero_whiteboard_server/src/handlers/object.rs:create: readparams["parent_frame_id"].as_u64()into the new field of the constructedBoardObject.update: mirror the existingparent_idblock (handle explicitnullto clear, missing → keep, integer → set).batch_update: unchanged.Dependencies: Step 1.
Step 3 — OpenRPC schema (depends on Step 2)
crates/hero_whiteboard_server/openrpc.json:components.schemas.BoardObject.properties, addparent_frame_id: { "type": "integer", "nullable": true, "description": "Parent frame object ID — set when this object lives inside a frame" }.object.createandobject.updateparamsarrays, add{ "name": "parent_frame_id", "required": false, "schema": { "type": "integer" } }.crates/hero_whiteboard_ui/static/openrpc.json— mirror the same edits.Dependencies: Step 2.
Step 4 — Frontend serialisation + sync (depends on Step 3)
crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js:serializeForServer: at the end beforereturn, setpayload.parent_frame_id = (objData.parent_frame_id != null) ? Number(objData.parent_frame_id) : null(usenullto actively clear server-side, matching theis_null()branch in the Rust handler).object.updatebranch: addparent_frame_id: p.parent_frame_idto the update params.applySyncUpdate: at the top of thetryblock after looking upexisting, setexisting.parent_frame_id = (obj.parent_frame_id != null) ? Number(obj.parent_frame_id) : null. No Konva mutation needed.object.deletedhandler: when the deleted object's local entry hastype === 'frame', walkWhiteboardObjects.getAllObjects()and clearparent_frame_idon any entry whose value matches the deleted id, before destroying the group.Dependencies: Step 3 wire format.
Step 5 — Frontend membership policy (depends on Step 4)
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js:recomputeParentFrame(node)near the existing frame helpers.dragstartcapture loop increateFramewith a registry lookup keyed onparent_frame_id.dragend, call the helper afterWhiteboardSync.onUpdate(group)and re-fireonUpdateif membership changed.opts.parent_frame_id, persist it on the registry entry, and runrecomputeParentFrame(group)for local creations.crates/hero_whiteboard_ui/static/web/js/whiteboard/app.js: passparent_frame_id: obj.parent_frame_idinto everyWhiteboardObjects.create*/ external create call increateObjectFromData.Dependencies: Step 4.
Step 6 — Verification
cargo fmt --all -- --check,cargo check --workspace,cargo clippy --workspace -- -D warnings,cargo test --workspace --lib,node --checkon modified JS modules.Acceptance Criteria
ON DELETE SET NULL) and client-side (via theobject.deletedcleanup insync.js).parent_frame_idround-trips throughobject.create,object.update,object.list,object.get, and the realtimeobject.updatedpayload.cargo fmt --all -- --check,cargo check --workspace,cargo clippy --workspace -- -D warnings,cargo test --workspace --libare clean.Notes / Caveats
getClientRect({ skipShadow: true })for both the moving object and the frames, matching the existing capture rule's geometry, so no visual regression for users who relied on the old behaviour for the in-frame case.zIndex()of those that contain the rect.partial_update_object(batch) path is intentionally NOT extended. Membership transitions only happen on a single non-framedragend, which always goes through the single-object branch offlushUpdates. Multi-select drag does not transition membership in this issue's scope.ALTER TABLE ADD COLUMN … REFERENCES.PRAGMA foreign_keys=ONis set inopen_db, so the cascade fires at runtime.Number(...)when reading the field for comparison and onnull(notundefined) when serialising for the wire so the server's "explicit null clears" branch fires.datablob —parent_frame_idis a first-class column.Test Results
Implementation Summary
Replaced the geometric "everything inside the frame's bounds at dragstart" capture rule (introduced in #89) with explicit, persisted parent-child membership. Each non-frame object now stores a nullable
parent_frame_idpointing to its containing frame, and frame drag captures children by id rather than visual overlap. Membership is set on creation when an object is dropped fully inside a frame, and is set/cleared on any non-framedragendbased on the drop position.Diff stat: 9 files, +160/-33, plus one new migration file.
Server (Rust)
crates/hero_whiteboard_server/src/migrations/007_object_parent_frame.sql— new migration:crates/hero_whiteboard_server/src/db/mod.rs— registered migration 007 inMigrations::new(vec![…]).crates/hero_whiteboard_server/src/db/models.rs— addedpub parent_frame_id: Option<u64>toBoardObject(next toparent_id, matching its serde shape).crates/hero_whiteboard_server/src/db/queries.rs— extendedcreate_object,get_object,list_objects,row_to_object, andupdate_objectto read/write the new column.partial_update_object(batch path) intentionally untouched.crates/hero_whiteboard_server/src/handlers/object.rs—createreadsparams["parent_frame_id"];updatemirrors the existingparent_idblock (explicitnullclears, missing leaves untouched, integer sets).OpenRPC
crates/hero_whiteboard_server/openrpc.jsonandcrates/hero_whiteboard_ui/static/openrpc.json(mirror) — addedparent_frame_idtoBoardObjectand to theobject.create/object.updateparam lists, matching the existingparent_idshape.Frontend —
crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.jsserializeForServer: emitsparent_frame_idfrom the local registry (Number or null) so the server's "explicit-null clears" branch fires correctly.flushUpdatesbranch: passesparent_frame_idin theobject.updatecall. Batch branch unchanged.applySyncUpdate: writesobj.parent_frame_idback onto the local registry entry before any type-specific branches.object.deletedhandler: when the deleted object is a frame, walks the local registry and clearsparent_frame_idon every matching child before destroying the group (defence in depth on top of the FK).Frontend —
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.jsrecomputeParentFrame(node): skips frames, computesnode.getClientRect({ skipShadow: true }), picks the topmost frame fully containing it (highestzIndexwins on overlap), updates the registry entry'sparent_frame_id, and returns true if it changed.createFramedragstart: replaced the geometric capture loop with a registry lookup capturing only nodes whoseparent_frame_id === Number(group.id()). Snapshot/dragmove/dragend behaviour preserved.dragendof every non-frame draggable (sticky, text, shape, document, image, emoji, drawing): after the existingWhiteboardSync.onUpdate(group), callsif (recomputeParentFrame(group)) WhiteboardSync.onUpdate(group);to persist and broadcast a membership change.createStickyNote,createTextBox,createShape,createDocument,createImage,createEmoji,createDrawing): registry entry now carriesparent_frame_id(fromopts.parent_frame_idif present, otherwise null). For local creates (noopts.id),recomputeParentFrame(group)runs after the registry write but beforeWhiteboardSync.onCreate, so a new object placed inside a frame inherits its membership.createFrameitself never setsparent_frame_id— frames don't auto-nest.Frontend —
crates/hero_whiteboard_ui/static/web/js/whiteboard/app.jscreateObjectFromData: passesparent_frame_id: obj.parent_frame_idinto everyWhiteboardObjects.create*and external module create call (sticky, text, shape, frame, document, image, emoji, drawing, calendar, kanban, mindmap, webframe).renderSingleObjectdelegates to this path so the single-object hydration is also covered.Verification
cargo fmt --all -- --check: cleancargo check --workspace: cleancargo clippy --workspace -- -D warnings: cleancargo test --workspace: pass (3 existing server tests, 0 failures)node --checkonsync.js,objects.js,app.js: cleanopenrpc.jsonfiles validated as JSON.Manual smoke test
object.updated.parent_frame_idcleared server-side (FKON DELETE SET NULL) and client-side (the newobject.deletedcleanup loop).Notes / scope
getClientRect({ skipShadow: true })for both the moving object and the frame, matching the prior capture geometry.zIndexof those whose rect contains the drop).partial_update_object(Ctrl+S "save all" batch) intentionally does NOT carryparent_frame_id. Membership transitions only happen on a single non-framedragendwhich always uses the single-object branch. The server's update handler treats a missing key as no-op, so existing values are preserved through batch paths.ALTER TABLE ADD COLUMN … REFERENCESsemantics;PRAGMA foreign_keys = ONis set inopen_db, so the cascade fires at runtime. Existing rows getNULLautomatically.calendar.js,kanban.js,mindmap.js,webframe.js) own their own dragend handlers and registry entries. They were intentionally left untouched in this issue's scope:app.jsalready passesparent_frame_idthrough theiropts, but those modules' own dragends do not yet recompute frame membership. Follow-up if needed: extend each widget'sdragendto callWhiteboardObjectsif it exposesrecomputeParentFrame, or duplicate the helper locally. The seven core types reproduce the user's bug and are fully covered.