Frame: drag with contained objects so it acts as a real container #89

Open
opened 2026-04-28 10:50:51 +00:00 by AhmedHanafy725 · 3 comments
Member

Problem

Frames look like containers — they have a dashed rectangle and a title — but they aren't actually containers. A Frame is a Konva.Group whose only children are its own bg and label (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

  1. Drop a frame.
  2. Place a sticky note inside the dashed rectangle.
  3. Drag the frame to a new position.
  4. The sticky note stays in place; the frame moves alone.

Affected files

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js — update createFrame to:
    • On dragstart, snapshot the set of objects whose bounding boxes are fully inside the frame's bounding box at that moment, plus their starting positions.
    • On dragmove / dragend, translate each captured object by the same delta the frame moved.
    • On dragend, sync each moved object via WhiteboardSync.onUpdate(...) so the new positions persist and propagate.

Expected behavior

  • At dragstart, the frame captures every other top-level object on the layer whose getClientRect is fully contained within the frame's bg rect (in stage coordinates). Stickies, text, shapes, documents, calendars, kanban, mindmaps, drawings, images, webframes, comments, and other frames all qualify if fully inside.
  • During the drag, all captured objects move with the frame as a unit (smooth, no visible lag).
  • On release, each moved object's new position is committed (history snapshot + sync update). The originating user sees no jump; remote users see all objects move atomically.
  • Objects that partially overlap the frame are NOT captured — only fully-contained ones — to avoid surprising behavior at the frame's edges. If the user wants partial-overlap behavior they can adjust the frame size first.
  • The frame itself still moves as today (its bg and label are part of the frame group; no change there).
  • Resizing the frame does NOT cascade to children. Only drag does.
  • Z-order is preserved — the contained objects stay at their original z-index relative to each other and to the frame.

Acceptance criteria

  • Dragging a frame moves every fully-contained object with it.
  • On release, each moved object's new position is persisted via WhiteboardSync.onUpdate(...) and visible in another window opened on the same board.
  • An object that overlaps the frame edge (not fully inside) does NOT move with the frame.
  • Resize behavior is unchanged (children are not pinned during resize).
  • Z-order is preserved.
  • No regression in single-frame drag without contained objects.
  • No regression in connectors — if a connector's endpoints are both inside the frame, the connector still tracks correctly (its endpoints are anchored to the moved objects).
  • Undo (Ctrl+Z) reverses the entire group move, not just the frame.
  • cargo check / clippy / fmt --check / test clean.

Notes

  • Capture once at dragstart, not on every dragmove — 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).
  • Use 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" means cand.x >= frame.x && cand.right <= frame.right && cand.y >= frame.y && cand.bottom <= frame.bottom.
  • For the move, use the delta from dragstart position, not the current position — this avoids drift from rounding.
  • Take a single 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).
  • Avoid recursion / infinite-loop risk: when iterating candidates, exclude the frame itself, and exclude any nested frame (capturing a containing-frame's contents-of-a-contained-frame would be wrong here — keep it one level).
## Problem Frames look like containers — they have a dashed rectangle and a title — but they aren't actually containers. A `Frame` is a `Konva.Group` whose only children are its own `bg` and `label` (`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 1. Drop a frame. 2. Place a sticky note inside the dashed rectangle. 3. Drag the frame to a new position. 4. The sticky note stays in place; the frame moves alone. ## Affected files - `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` — update `createFrame` to: - On `dragstart`, snapshot the set of objects whose bounding boxes are fully inside the frame's bounding box at that moment, plus their starting positions. - On `dragmove` / `dragend`, translate each captured object by the same delta the frame moved. - On `dragend`, sync each moved object via `WhiteboardSync.onUpdate(...)` so the new positions persist and propagate. ## Expected behavior - At dragstart, the frame captures every other top-level object on the layer whose `getClientRect` is fully contained within the frame's `bg` rect (in stage coordinates). Stickies, text, shapes, documents, calendars, kanban, mindmaps, drawings, images, webframes, comments, and other frames all qualify if fully inside. - During the drag, all captured objects move with the frame as a unit (smooth, no visible lag). - On release, each moved object's new position is committed (history snapshot + sync update). The originating user sees no jump; remote users see all objects move atomically. - Objects that *partially* overlap the frame are NOT captured — only fully-contained ones — to avoid surprising behavior at the frame's edges. If the user wants partial-overlap behavior they can adjust the frame size first. - The frame itself still moves as today (its `bg` and `label` are part of the frame group; no change there). - Resizing the frame does NOT cascade to children. Only drag does. - Z-order is preserved — the contained objects stay at their original z-index relative to each other and to the frame. ## Acceptance criteria - [ ] Dragging a frame moves every fully-contained object with it. - [ ] On release, each moved object's new position is persisted via `WhiteboardSync.onUpdate(...)` and visible in another window opened on the same board. - [ ] An object that overlaps the frame edge (not fully inside) does NOT move with the frame. - [ ] Resize behavior is unchanged (children are not pinned during resize). - [ ] Z-order is preserved. - [ ] No regression in single-frame drag without contained objects. - [ ] No regression in connectors — if a connector's endpoints are both inside the frame, the connector still tracks correctly (its endpoints are anchored to the moved objects). - [ ] Undo (`Ctrl+Z`) reverses the entire group move, not just the frame. - [ ] `cargo check / clippy / fmt --check / test` clean. ## Notes - Capture once at `dragstart`, not on every `dragmove` — 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). - Use `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" means `cand.x >= frame.x && cand.right <= frame.right && cand.y >= frame.y && cand.bottom <= frame.bottom`. - For the move, use the delta from `dragstart` position, not the current position — this avoids drift from rounding. - Take a single `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). - Avoid recursion / infinite-loop risk: when iterating candidates, exclude the frame itself, and exclude any nested frame (capturing a containing-frame's contents-of-a-contained-frame would be wrong here — keep it one level).
Author
Member

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

  • At 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 via WhiteboardSync.onUpdate(...) and produces a sync update for other windows.
  • A single undo (Ctrl+Z) reverts the entire group move (frame + captured children).
  • Resize is unchanged — only drag cascades to children.
  • An object that overlaps the frame's edge (not fully contained) is NOT captured.
  • Connectors whose endpoints are both captured stay attached during the drag (no visual lag) — fire dragmove on each child after moving so connectors' existing tracking handlers run.
  • No regression in single-frame drag without contained objects, in resize, in select/transform, or in connectors with one endpoint inside and one outside (the outside endpoint stays put; the inside endpoint moves; the connector tracks).
  • Diff is contained to:
    • crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js
    • crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js

Files to Modify

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js — add a batch(work) helper that collects every push(...) made inside work into a single {type:'batch', actions:[...]} entry. Extend undo and redo to recurse action.actions in reverse / forward order using the existing per-type logic.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js::createFrame — add dragmove handler; extend dragstart to capture children; extend dragend to wrap commits in WhiteboardHistory.batch and sync each captured node.

No server / SDK / openrpc / DB changes.

Implementation Plan

Step 1: history.js — add batch and teach undo/redo about batched actions

Files: crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js

  1. Add module-scope var _collectingBatch = null;. Inside push(action), if _collectingBatch is non-null AND _enabled, append to _collectingBatch instead of pushing to undoStack; otherwise behave as today.
  2. Add a public:
    function batch(work) {
        if (!_enabled || _collectingBatch) { work(); return; }
        _collectingBatch = [];
        try { work(); } finally {
            var captured = _collectingBatch;
            _collectingBatch = null;
            if (captured.length > 0) push({ type: 'batch', actions: captured });
        }
    }
    
  3. Refactor the per-type bodies of undo and redo into local helpers applyUndo(action) / applyRedo(action) so the new batch branch can reuse them. Keep the surrounding behavior (deselect, hide properties, updateButtons) at the public undo/redo boundary.
  4. Add the batch branch:
    • undo: when action.type === 'batch', iterate action.actions in reverse and call applyUndo(action.actions[i]) on each.
    • redo: when action.type === 'batch', iterate forward.
  5. Export batch in the module's public API.
  6. Update the block comment at the top of push(...) (history.js:17) to document the new batch action type.

Dependencies: none.

Step 2: objects.js::createFrame — capture, move, commit

Files: crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js

  1. Add module-scope-of-the-frame _capturedChildren = []; and _dragStartFrameX, _dragStartFrameY inside the frame's closure (declare them inside createFrame so they're per-frame).
  2. dragstart: existing handler snapshots history for the frame. Extend it to also capture children:
    group.on('dragstart', function() {
        WhiteboardHistory.snapshotBefore(id);
        _dragStartFrameX = group.x();
        _dragStartFrameY = group.y();
    
        // Snapshot frame's bounding box once, in stage coordinates.
        var frameRect = group.getClientRect({ skipShadow: true });
    
        _capturedChildren = [];
        var layer = WhiteboardCanvas.getObjectLayer();
        layer.getChildren().forEach(function(node) {
            if (node === group) return;
            // Only top-level objects (have an id), skip anchors/transformers/etc.
            if (!node.hasName || !node.hasName('object')) return;
            var cr = node.getClientRect({ skipShadow: true });
            if (cr.x >= frameRect.x &&
                cr.y >= frameRect.y &&
                cr.x + cr.width  <= frameRect.x + frameRect.width &&
                cr.y + cr.height <= frameRect.y + frameRect.height) {
                _capturedChildren.push({ node: node, startX: node.x(), startY: node.y() });
                WhiteboardHistory.snapshotBefore(node.id());
            }
        });
    });
    
  3. dragmove: new handler that applies the frame's drag delta to each captured child and triggers connector tracking:
    group.on('dragmove', function() {
        if (!_capturedChildren || _capturedChildren.length === 0) return;
        var dx = group.x() - _dragStartFrameX;
        var dy = group.y() - _dragStartFrameY;
        for (var i = 0; i < _capturedChildren.length; i++) {
            var c = _capturedChildren[i];
            c.node.x(c.startX + dx);
            c.node.y(c.startY + dy);
            // Connectors hook into endpoint dragmove events, so fire it manually
            // — programmatic x()/y() changes don't emit drag events on their own.
            c.node.fire('dragmove', { target: c.node }, true);
        }
        WhiteboardCanvas.getObjectLayer().batchDraw();
    });
    
  4. dragend: replace the existing handler with a batch commit:
    group.on('dragend', function() {
        WhiteboardHistory.batch(function() {
            WhiteboardHistory.commitUpdate(id);
            for (var i = 0; i < _capturedChildren.length; i++) {
                WhiteboardHistory.commitUpdate(_capturedChildren[i].node.id());
            }
        });
        WhiteboardSync.onUpdate(group);
        for (var i = 0; i < _capturedChildren.length; i++) {
            WhiteboardSync.onUpdate(_capturedChildren[i].node);
        }
        _capturedChildren = [];
    });
    
  5. The existing dblclick rename binding on label (issue #88) is unaffected.

Dependencies: Step 1 (the batch API).

Acceptance Criteria

  • Dragging a frame moves every fully-contained top-level object with it.
  • Each moved object's new position is persisted via WhiteboardSync.onUpdate(...) and visible in another window opened on the same board.
  • An object that overlaps the frame edge (not fully inside) does NOT move with the frame.
  • Resize behavior is unchanged (children are not pinned during resize).
  • Z-order is preserved.
  • Connectors track during the drag (no visual lag).
  • One Ctrl+Z reverts the entire group move (frame + all captured children).
  • No regression in dragging a frame with no contained objects, or in any non-frame object's drag/sync behavior.
  • cargo test --workspace, cargo clippy --workspace -- -D warnings, cargo fmt --all -- --check clean.

Notes

  • Capture once at dragstart, not on every dragmove — the contained set should not change mid-drag (a sticky's relative position to the frame is constant during the drag).
  • node.hasName('object') filters out anchors, transformers, snap guides, the connector layer, etc. The frame itself has the names object frame; node === group excludes it from its own capture.
  • For connectors: the existing connectors.js registers a dragmove.connector_<id> handler on the endpoint group. Firing dragmove programmatically triggers all matching listeners, including the connector's tracking.
  • The batch history extension is minimal and self-contained. It will also help future multi-select drag work, but that is out of scope here.
  • A nested-frame edge case: if frame A contains frame B which contains a sticky, dragging A captures both B and the sticky directly (both are fully inside A's bounds). They all move together — the right outcome — and B's own drag handlers don't fire (we're dragging A). No special handling needed.
## 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 - At `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 via `WhiteboardSync.onUpdate(...)` and produces a sync update for other windows. - A single undo (`Ctrl+Z`) reverts the entire group move (frame + captured children). - Resize is unchanged — only drag cascades to children. - An object that overlaps the frame's edge (not fully contained) is NOT captured. - Connectors whose endpoints are both captured stay attached during the drag (no visual lag) — fire `dragmove` on each child after moving so connectors' existing tracking handlers run. - No regression in single-frame drag without contained objects, in resize, in select/transform, or in connectors with one endpoint inside and one outside (the outside endpoint stays put; the inside endpoint moves; the connector tracks). - Diff is contained to: - `crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js` - `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` ### Files to Modify - `crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js` — add a `batch(work)` helper that collects every `push(...)` made inside `work` into a single `{type:'batch', actions:[...]}` entry. Extend `undo` and `redo` to recurse `action.actions` in reverse / forward order using the existing per-type logic. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js::createFrame` — add `dragmove` handler; extend `dragstart` to capture children; extend `dragend` to wrap commits in `WhiteboardHistory.batch` and sync each captured node. No server / SDK / openrpc / DB changes. ### Implementation Plan #### Step 1: `history.js` — add `batch` and teach undo/redo about batched actions Files: `crates/hero_whiteboard_ui/static/web/js/whiteboard/history.js` 1. Add module-scope `var _collectingBatch = null;`. Inside `push(action)`, if `_collectingBatch` is non-null AND `_enabled`, append to `_collectingBatch` instead of pushing to `undoStack`; otherwise behave as today. 2. Add a public: ```js function batch(work) { if (!_enabled || _collectingBatch) { work(); return; } _collectingBatch = []; try { work(); } finally { var captured = _collectingBatch; _collectingBatch = null; if (captured.length > 0) push({ type: 'batch', actions: captured }); } } ``` 3. Refactor the per-type bodies of `undo` and `redo` into local helpers `applyUndo(action)` / `applyRedo(action)` so the new `batch` branch can reuse them. Keep the surrounding behavior (deselect, hide properties, updateButtons) at the public `undo`/`redo` boundary. 4. Add the `batch` branch: - `undo`: when `action.type === 'batch'`, iterate `action.actions` in reverse and call `applyUndo(action.actions[i])` on each. - `redo`: when `action.type === 'batch'`, iterate forward. 5. Export `batch` in the module's public API. 6. Update the block comment at the top of `push(...)` (`history.js:17`) to document the new `batch` action type. Dependencies: none. #### Step 2: `objects.js::createFrame` — capture, move, commit Files: `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` 1. Add module-scope-of-the-frame `_capturedChildren = [];` and `_dragStartFrameX, _dragStartFrameY` inside the frame's closure (declare them inside `createFrame` so they're per-frame). 2. **`dragstart`**: existing handler snapshots history for the frame. Extend it to also capture children: ```js group.on('dragstart', function() { WhiteboardHistory.snapshotBefore(id); _dragStartFrameX = group.x(); _dragStartFrameY = group.y(); // Snapshot frame's bounding box once, in stage coordinates. var frameRect = group.getClientRect({ skipShadow: true }); _capturedChildren = []; var layer = WhiteboardCanvas.getObjectLayer(); layer.getChildren().forEach(function(node) { if (node === group) return; // Only top-level objects (have an id), skip anchors/transformers/etc. if (!node.hasName || !node.hasName('object')) return; var cr = node.getClientRect({ skipShadow: true }); if (cr.x >= frameRect.x && cr.y >= frameRect.y && cr.x + cr.width <= frameRect.x + frameRect.width && cr.y + cr.height <= frameRect.y + frameRect.height) { _capturedChildren.push({ node: node, startX: node.x(), startY: node.y() }); WhiteboardHistory.snapshotBefore(node.id()); } }); }); ``` 3. **`dragmove`**: new handler that applies the frame's drag delta to each captured child and triggers connector tracking: ```js group.on('dragmove', function() { if (!_capturedChildren || _capturedChildren.length === 0) return; var dx = group.x() - _dragStartFrameX; var dy = group.y() - _dragStartFrameY; for (var i = 0; i < _capturedChildren.length; i++) { var c = _capturedChildren[i]; c.node.x(c.startX + dx); c.node.y(c.startY + dy); // Connectors hook into endpoint dragmove events, so fire it manually // — programmatic x()/y() changes don't emit drag events on their own. c.node.fire('dragmove', { target: c.node }, true); } WhiteboardCanvas.getObjectLayer().batchDraw(); }); ``` 4. **`dragend`**: replace the existing handler with a batch commit: ```js group.on('dragend', function() { WhiteboardHistory.batch(function() { WhiteboardHistory.commitUpdate(id); for (var i = 0; i < _capturedChildren.length; i++) { WhiteboardHistory.commitUpdate(_capturedChildren[i].node.id()); } }); WhiteboardSync.onUpdate(group); for (var i = 0; i < _capturedChildren.length; i++) { WhiteboardSync.onUpdate(_capturedChildren[i].node); } _capturedChildren = []; }); ``` 5. The existing dblclick rename binding on `label` (issue #88) is unaffected. Dependencies: Step 1 (the `batch` API). ### Acceptance Criteria - [ ] Dragging a frame moves every fully-contained top-level object with it. - [ ] Each moved object's new position is persisted via `WhiteboardSync.onUpdate(...)` and visible in another window opened on the same board. - [ ] An object that overlaps the frame edge (not fully inside) does NOT move with the frame. - [ ] Resize behavior is unchanged (children are not pinned during resize). - [ ] Z-order is preserved. - [ ] Connectors track during the drag (no visual lag). - [ ] One Ctrl+Z reverts the entire group move (frame + all captured children). - [ ] No regression in dragging a frame with no contained objects, or in any non-frame object's drag/sync behavior. - [ ] `cargo test --workspace`, `cargo clippy --workspace -- -D warnings`, `cargo fmt --all -- --check` clean. ### Notes - Capture once at dragstart, not on every dragmove — the contained set should not change mid-drag (a sticky's relative position to the frame is constant during the drag). - `node.hasName('object')` filters out anchors, transformers, snap guides, the connector layer, etc. The frame itself has the names `object frame`; `node === group` excludes it from its own capture. - For connectors: the existing `connectors.js` registers a `dragmove.connector_<id>` handler on the endpoint group. Firing `dragmove` programmatically triggers all matching listeners, including the connector's tracking. - The `batch` history extension is minimal and self-contained. It will also help future multi-select drag work, but that is out of scope here. - A nested-frame edge case: if frame A contains frame B which contains a sticky, dragging A captures both B and the sticky directly (both are fully inside A's bounds). They all move together — the right outcome — and B's own drag handlers don't fire (we're dragging A). No special handling needed.
Author
Member

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:

  1. Drop a frame and place a sticky inside it. Drag the frame — the sticky moves with it.
  2. Place a sticky that overlaps the frame edge (partially outside). Drag — the sticky stays put.
  3. Drag the frame, then press Ctrl+Z — both the frame and any captured children revert to their pre-drag positions in a single undo step.
  4. Open the same board in a second window. Drag the frame in window A — confirm frame and contained sticky positions update in window B.
  5. Connector test: place two stickies inside the frame, connect them, drag the frame — connector tracks during the drag (no visual lag).
  6. Connector with one endpoint inside and one outside — drag the frame; only the inside endpoint moves; connector stretches and tracks.
  7. Resize the frame (no children move; behavior unchanged).
  8. Nested frame: drop frame B inside frame A, place a sticky inside B, drag A — A, B, and the sticky all move together.
  9. Drag a frame with no contained objects — just the frame moves; behavior unchanged.
## 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: 1. Drop a frame and place a sticky inside it. Drag the frame — the sticky moves with it. 2. Place a sticky that overlaps the frame edge (partially outside). Drag — the sticky stays put. 3. Drag the frame, then press Ctrl+Z — both the frame and any captured children revert to their pre-drag positions in a single undo step. 4. Open the same board in a second window. Drag the frame in window A — confirm frame and contained sticky positions update in window B. 5. Connector test: place two stickies inside the frame, connect them, drag the frame — connector tracks during the drag (no visual lag). 6. Connector with one endpoint inside and one outside — drag the frame; only the inside endpoint moves; connector stretches and tracks. 7. Resize the frame (no children move; behavior unchanged). 8. Nested frame: drop frame B inside frame A, place a sticky inside B, drag A — A, B, and the sticky all move together. 9. Drag a frame with no contained objects — just the frame moves; behavior unchanged.
Author
Member

Implementation Summary

2 files changed, +127 / -23.

history.js

  • Added a _collectingBatch mode flag. When batch(work) is active, push(...) calls accumulate into a local list instead of going to undoStack. On batch return, the captured list is pushed as a single {type:'batch', actions:[...]} entry.
  • Re-entrant batch calls (batch inside batch) are flattened into the outer batch.
  • Refactored undo / redo to delegate per-action logic to small applyUndo / applyRedo helpers. Added a batch branch to each: undo recurses in reverse, redo recurses forward.
  • Exposed batch on the public API.
  • Documented the new batch action type in the push(...) block comment.

objects.js::createFrame

  • New per-frame closure state: capturedChildren, dragStartFrameX/Y.
  • dragstart: in addition to snapshotting the frame's history, snapshots frame.getClientRect({skipShadow:true}) and walks getObjectLayer().getChildren(). Each top-level .object whose bounding box is fully inside the frame is captured ({node, startX, startY}) and WhiteboardHistory.snapshotBefore(node.id()).
  • dragmove (new): computes dx/dy from the frame's current x/y minus its dragStart... coords; applies to each captured node's x/y; fires dragmove on each so connectors.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. Then WhiteboardSync.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

  • UI-only change; no server / SDK / openrpc / DB edits.
  • Only fully-contained objects are captured. Edge-overlapping objects stay put.
  • Connectors track during the drag because we manually node.fire('dragmove') after each programmatic position update.
  • Capture happens once at dragstart. The set doesn't change mid-drag, even if a captured object would visually leave the frame's bounds during the move.
  • The batch history extension is generic and self-contained; it'll also be useful for any future multi-select drag feature.
## Implementation Summary 2 files changed, +127 / -23. ### `history.js` - Added a `_collectingBatch` mode flag. When `batch(work)` is active, `push(...)` calls accumulate into a local list instead of going to `undoStack`. On `batch` return, the captured list is pushed as a single `{type:'batch', actions:[...]}` entry. - Re-entrant `batch` calls (batch inside batch) are flattened into the outer batch. - Refactored `undo` / `redo` to delegate per-action logic to small `applyUndo` / `applyRedo` helpers. Added a `batch` branch to each: `undo` recurses in reverse, `redo` recurses forward. - Exposed `batch` on the public API. - Documented the new `batch` action type in the `push(...)` block comment. ### `objects.js::createFrame` - New per-frame closure state: `capturedChildren`, `dragStartFrameX/Y`. - `dragstart`: in addition to snapshotting the frame's history, snapshots `frame.getClientRect({skipShadow:true})` and walks `getObjectLayer().getChildren()`. Each top-level `.object` whose bounding box is fully inside the frame is captured (`{node, startX, startY}`) and `WhiteboardHistory.snapshotBefore(node.id())`. - `dragmove` (new): computes `dx/dy` from the frame's current x/y minus its `dragStart...` coords; applies to each captured node's x/y; fires `dragmove` on each so `connectors.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. Then `WhiteboardSync.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 - UI-only change; no server / SDK / openrpc / DB edits. - Only **fully-contained** objects are captured. Edge-overlapping objects stay put. - Connectors track during the drag because we manually `node.fire('dragmove')` after each programmatic position update. - Capture happens once at `dragstart`. The set doesn't change mid-drag, even if a captured object would visually leave the frame's bounds during the move. - The `batch` history extension is generic and self-contained; it'll also be useful for any future multi-select drag feature.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#89
No description provided.