Frame: drag with contained objects so it acts as a real container #89
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#89
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
Frames look like containers — they have a dashed rectangle and a title — but they aren't actually containers. A
Frameis aKonva.Groupwhose only children are its ownbgandlabel(objects.js:785-839). Sticky notes / shapes / text / etc. that visually sit inside the frame's dashed rectangle are siblings of the frame in the Konva layer, not children of the frame group.Result: when you drag a frame to a new position on the canvas, only the dashed rectangle and the label move. Every object visually "inside" the frame stays exactly where it was, leaving the frame visually orphaned. Users expect Miro / Figma / FigJam-style frames where moving a frame brings its contents along.
Reproduction
Affected files
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js— updatecreateFrameto:dragstart, snapshot the set of objects whose bounding boxes are fully inside the frame's bounding box at that moment, plus their starting positions.dragmove/dragend, translate each captured object by the same delta the frame moved.dragend, sync each moved object viaWhiteboardSync.onUpdate(...)so the new positions persist and propagate.Expected behavior
getClientRectis fully contained within the frame'sbgrect (in stage coordinates). Stickies, text, shapes, documents, calendars, kanban, mindmaps, drawings, images, webframes, comments, and other frames all qualify if fully inside.bgandlabelare part of the frame group; no change there).Acceptance criteria
WhiteboardSync.onUpdate(...)and visible in another window opened on the same board.Ctrl+Z) reverses the entire group move, not just the frame.cargo check / clippy / fmt --check / testclean.Notes
dragstart, not on everydragmove— the contained set should not change mid-drag (a sticky doesn't "escape" the frame because the user is dragging the frame; the sticky's relative position to the frame is constant during the drag).frame.getClientRect({skipShadow:true, skipStroke:false})to get the frame's bounding box in stage coords; for each candidate object, do the same. "Fully contained" meanscand.x >= frame.x && cand.right <= frame.right && cand.y >= frame.y && cand.bottom <= frame.bottom.dragstartposition, not the current position — this avoids drift from rounding.WhiteboardHistory.snapshotBefore(...)for each captured object at dragstart so undo restores them all in one step. Push a single'multi-move'history entry on dragend (or N entries — whichever the existing history infrastructure prefers).Implementation Spec for Issue #89
Objective
Make a frame act like a real container: dragging the frame brings every other top-level object that is fully inside its bounding box at dragstart along with it. The action is a single undo step. Connectors with both endpoints inside the frame keep tracking their endpoints during the drag.
Requirements
dragstart, capture every other top-level object on the layer whose bounding box is fully inside the frame's bounding box.dragmove: each captured object moves by the same delta the frame has moved.dragend: each captured object's new position is persisted viaWhiteboardSync.onUpdate(...)and produces a sync update for other windows.Ctrl+Z) reverts the entire group move (frame + captured children).dragmoveon each child after moving so connectors' existing tracking handlers run.crates/hero_whiteboard_ui/static/web/js/whiteboard/history.jscrates/hero_whiteboard_ui/static/web/js/whiteboard/objects.jsFiles to Modify
crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js— add abatch(work)helper that collects everypush(...)made insideworkinto a single{type:'batch', actions:[...]}entry. Extendundoandredoto recurseaction.actionsin reverse / forward order using the existing per-type logic.crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js::createFrame— adddragmovehandler; extenddragstartto capture children; extenddragendto wrap commits inWhiteboardHistory.batchand sync each captured node.No server / SDK / openrpc / DB changes.
Implementation Plan
Step 1:
history.js— addbatchand teach undo/redo about batched actionsFiles:
crates/hero_whiteboard_ui/static/web/js/whiteboard/history.jsvar _collectingBatch = null;. Insidepush(action), if_collectingBatchis non-null AND_enabled, append to_collectingBatchinstead of pushing toundoStack; otherwise behave as today.undoandredointo local helpersapplyUndo(action)/applyRedo(action)so the newbatchbranch can reuse them. Keep the surrounding behavior (deselect, hide properties, updateButtons) at the publicundo/redoboundary.batchbranch:undo: whenaction.type === 'batch', iterateaction.actionsin reverse and callapplyUndo(action.actions[i])on each.redo: whenaction.type === 'batch', iterate forward.batchin the module's public API.push(...)(history.js:17) to document the newbatchaction type.Dependencies: none.
Step 2:
objects.js::createFrame— capture, move, commitFiles:
crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js_capturedChildren = [];and_dragStartFrameX, _dragStartFrameYinside the frame's closure (declare them insidecreateFrameso they're per-frame).dragstart: existing handler snapshots history for the frame. Extend it to also capture children:dragmove: new handler that applies the frame's drag delta to each captured child and triggers connector tracking:dragend: replace the existing handler with a batch commit:label(issue #88) is unaffected.Dependencies: Step 1 (the
batchAPI).Acceptance Criteria
WhiteboardSync.onUpdate(...)and visible in another window opened on the same board.cargo test --workspace,cargo clippy --workspace -- -D warnings,cargo fmt --all -- --checkclean.Notes
node.hasName('object')filters out anchors, transformers, snap guides, the connector layer, etc. The frame itself has the namesobject frame;node === groupexcludes it from its own capture.connectors.jsregisters adragmove.connector_<id>handler on the endpoint group. Firingdragmoveprogrammatically triggers all matching listeners, including the connector's tracking.batchhistory extension is minimal and self-contained. It will also help future multi-select drag work, but that is out of scope here.Test Results
cargo test -p hero_whiteboard_server: 3 passed (existing soft-delete and unique-name regressions stay green).cargo clippy --workspace -- -D warnings: clean.cargo fmt --all -- --check: clean.cargo check --workspace: clean.node --check history.js: parses cleanly.node --check objects.js: parses cleanly.This is a UI-only behavior; there are no server-side tests for the drag flow itself. Manual verification recommended:
Implementation Summary
2 files changed, +127 / -23.
history.js_collectingBatchmode flag. Whenbatch(work)is active,push(...)calls accumulate into a local list instead of going toundoStack. Onbatchreturn, the captured list is pushed as a single{type:'batch', actions:[...]}entry.batchcalls (batch inside batch) are flattened into the outer batch.undo/redoto delegate per-action logic to smallapplyUndo/applyRedohelpers. Added abatchbranch to each:undorecurses in reverse,redorecurses forward.batchon the public API.batchaction type in thepush(...)block comment.objects.js::createFramecapturedChildren,dragStartFrameX/Y.dragstart: in addition to snapshotting the frame's history, snapshotsframe.getClientRect({skipShadow:true})and walksgetObjectLayer().getChildren(). Each top-level.objectwhose bounding box is fully inside the frame is captured ({node, startX, startY}) andWhiteboardHistory.snapshotBefore(node.id()).dragmove(new): computesdx/dyfrom the frame's current x/y minus itsdragStart...coords; applies to each captured node's x/y; firesdragmoveon each soconnectors.js's endpoint listeners run and the connector stays attached during the drag.dragend: replaced by a single-batch commit.WhiteboardHistory.batch(...)wraps the commitUpdate calls for the frame + every captured child so a single Ctrl+Z reverts the entire group move. ThenWhiteboardSync.onUpdate(...)fires for the frame + each child so the change syncs.Verification
cargo test -p hero_whiteboard_server: 3 passed (existing soft-delete and unique-name regressions stay green).cargo clippy --workspace -- -D warnings: clean.cargo fmt --all -- --check: clean.cargo check --workspace: clean.node --check history.js: parses cleanly.node --check objects.js: parses cleanly.Notes / caveats
node.fire('dragmove')after each programmatic position update.dragstart. The set doesn't change mid-drag, even if a captured object would visually leave the frame's bounds during the move.batchhistory extension is generic and self-contained; it'll also be useful for any future multi-select drag feature.