Pasting twice in a row stacks copies on top of each other (use cumulative offset) #145

Open
opened 2026-05-05 10:31:52 +00:00 by AhmedHanafy725 · 3 comments
Member

Bug / UX gap

Every paste() and duplicate() call uses a fixed 20px offset from the original. So Ctrl+V Ctrl+V Ctrl+V produces three copies all stacked at original + 20 — the second and third pastes land on top of the first paste, looking exactly like the freehand-drawing bug from issue #144 (one duplicate visible, the rest invisible underneath).

The industry convention (Figma, Miro, PowerPoint, Google Slides) is cumulative offset: each paste is offset from the previous paste, not from the original. Ctrl+V N times produces N distinct copies cascading diagonally.

Reproduction

  1. Create any object (sticky / shape / drawing / etc.).
  2. Select it, press Ctrl+C.
  3. Press Ctrl+V three times.
  4. Drag the topmost selection — only one copy moves; two more copies are still stacked at the same position underneath.
  5. Same with Ctrl+D pressed three times in a row on the same selection.

Desired behavior

  • First paste from a new clipboard entry: original + (PASTE_OFFSET, PASTE_OFFSET) (today's behavior).
  • Each subsequent paste from the same clipboard entry: + PASTE_OFFSET further down-right (cumulative cascade).
  • Each subsequent duplicate from the same selection: cumulative cascade as well.
  • A new copy() resets the cascade so the next paste starts at offset 1 again.
  • For multi-select copy/paste: the relative geometry of the group is preserved (existing behavior); only the group as a whole cascades.

Affected file

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js — only file in scope.

Implementation sketch

  • Add a pasteCount (integer) at module scope alongside the existing clipboard array.
  • copy() resets pasteCount = 0 so a fresh copy starts the cascade over.
  • paste() increments pasteCount, then calls pasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount).
  • duplicate() does the same against its captured items snapshot — needs its own counter that resets when the selection changes (simplest: derive the counter from a hash/sentinel of the current selection, or increment a separate duplicateCount and reset it whenever WhiteboardTools.getTransformer().nodes() differs from the last duplicate target).
  • The drawing-segment translation added in #144 already uses offsetX / offsetY from pasteItems, so it benefits automatically without any further change.

Acceptance criteria

  • Ctrl+C then Ctrl+V three times produces three distinct copies cascading diagonally (each shifted +20px from the previous, total +20 / +40 / +60 from the original).
  • Ctrl+D three times on the same selection produces the same diagonal cascade.
  • After a new copy() (or selecting a different object and copying it), the next paste starts the cascade over at +20.
  • Multi-select copy/paste preserves relative geometry within the group; the group as a whole cascades.
  • Drawings cascade correctly (the segment translation from #144 picks up the cumulative offset automatically).
  • No regression in pasteFromOS() (that path uses _pastePos() and is independent of the cascade counter).

Out of scope

  • Pointer-anchored paste (Sketch / Excalidraw style: paste at cursor position when the cursor is over the canvas). That's a nicer UX but requires tracking last-known pointer position and reasoning about hover state — keep it for a follow-up issue.
## Bug / UX gap Every `paste()` and `duplicate()` call uses a fixed 20px offset from the original. So Ctrl+V Ctrl+V Ctrl+V produces three copies all stacked at `original + 20` — the second and third pastes land on top of the first paste, looking exactly like the freehand-drawing bug from issue #144 (one duplicate visible, the rest invisible underneath). The industry convention (Figma, Miro, PowerPoint, Google Slides) is **cumulative offset**: each paste is offset from the *previous* paste, not from the original. Ctrl+V N times produces N distinct copies cascading diagonally. ## Reproduction 1. Create any object (sticky / shape / drawing / etc.). 2. Select it, press Ctrl+C. 3. Press Ctrl+V three times. 4. Drag the topmost selection — only one copy moves; two more copies are still stacked at the same position underneath. 5. Same with Ctrl+D pressed three times in a row on the same selection. ## Desired behavior - First paste from a new clipboard entry: `original + (PASTE_OFFSET, PASTE_OFFSET)` (today's behavior). - Each subsequent paste from the same clipboard entry: `+ PASTE_OFFSET` further down-right (cumulative cascade). - Each subsequent duplicate from the same selection: cumulative cascade as well. - A new `copy()` resets the cascade so the next paste starts at offset 1 again. - For multi-select copy/paste: the relative geometry of the group is preserved (existing behavior); only the group as a whole cascades. ## Affected file - `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` — only file in scope. ## Implementation sketch - Add a `pasteCount` (integer) at module scope alongside the existing `clipboard` array. - `copy()` resets `pasteCount = 0` so a fresh copy starts the cascade over. - `paste()` increments `pasteCount`, then calls `pasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount)`. - `duplicate()` does the same against its captured `items` snapshot — needs its own counter that resets when the selection changes (simplest: derive the counter from a hash/sentinel of the current selection, or increment a separate `duplicateCount` and reset it whenever `WhiteboardTools.getTransformer().nodes()` differs from the last duplicate target). - The drawing-segment translation added in #144 already uses `offsetX` / `offsetY` from `pasteItems`, so it benefits automatically without any further change. ## Acceptance criteria - [ ] Ctrl+C then Ctrl+V three times produces three distinct copies cascading diagonally (each shifted +20px from the previous, total +20 / +40 / +60 from the original). - [ ] Ctrl+D three times on the same selection produces the same diagonal cascade. - [ ] After a new `copy()` (or selecting a different object and copying it), the next paste starts the cascade over at +20. - [ ] Multi-select copy/paste preserves relative geometry within the group; the group as a whole cascades. - [ ] Drawings cascade correctly (the segment translation from #144 picks up the cumulative offset automatically). - [ ] No regression in `pasteFromOS()` (that path uses `_pastePos()` and is independent of the cascade counter). ## Out of scope - Pointer-anchored paste (Sketch / Excalidraw style: paste at cursor position when the cursor is over the canvas). That's a nicer UX but requires tracking last-known pointer position and reasoning about hover state — keep it for a follow-up issue.
Author
Member

Implementation Spec for Issue #145

Objective

Replace the fixed PASTE_OFFSET cascade with a cumulative-offset model so that successive paste() and duplicate() calls produce a diagonal cascade (+20, +40, +60, ...) instead of stacking copies on top of each other. The change is module-local to clipboard.js: add small counters, multiply the base offset by the counter, and reset on the appropriate triggers (new copy() for paste; selection change for duplicate). pasteFromOS() and pasteItems() are not touched.

Requirements

  • Pressing Ctrl+V N times after a single Ctrl+C produces N distinct copies at offsets +20, +40, ..., +N*20 from the original.
  • Pressing Ctrl+D N times on the same selection produces the same diagonal cascade.
  • Calling copy() (Ctrl+C, right-click Copy, Cut) resets the paste cascade so the next paste() lands at +20.
  • Calling duplicate() on a different selection than the previous duplicate() resets the duplicate cascade so the next dup lands at +20 from the new selection.
  • Multi-select copy/paste and copy/duplicate preserve relative geometry within the group.
  • Drawings cascade correctly (already handled inside pasteItems from #144 — it benefits automatically from larger offsets).
  • pasteFromOS() is unchanged (pointer-anchored).
  • Right-click "Paste" / "Duplicate" benefit automatically because contextmenu.js calls WhiteboardClipboard.paste() / .duplicate().

Files to Modify/Create

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js — add module-scope state (pasteCount, duplicateCount, lastDuplicateKey); modify copy(), paste(), duplicate(). No other files in scope.

Implementation Plan

Step 1: Add cascade state at module scope

Files: clipboard.js

  • Next to var clipboard = []; and var PASTE_OFFSET = 20;, add var pasteCount = 0;, var duplicateCount = 0;, var lastDuplicateKey = '';.
  • Add a private helper selectionKey(nodes) that returns a stable signature: map each node to node.id(), filter falsy, sort, join('|'). Sorted-string is one line, dependency-free, and order-independent.
    Dependencies: none

Step 2: Reset paste cascade in copy()

Files: clipboard.js

  • After the early-return guards and the clipboard = [] assignment, set pasteCount = 0; (just before the nodes.forEach(...) serialization loop).
  • cut() already calls copy(), so the reset covers the cut path automatically.
    Dependencies: Step 1

Step 3: Cumulative offset in paste()

Files: clipboard.js

  • After the existing clipboard.length === 0 guard, increment pasteCount, compute var off = PASTE_OFFSET * pasteCount;, and call pasteItems(clipboard, off, off).
  • Leave the setTool('select') / setActive('select') / selectNodes(created) flow untouched.
    Dependencies: Step 2

Step 4: Cumulative offset + selection-change reset in duplicate()

Files: clipboard.js

  • After the nodes.length === 0 guard, compute var key = selectionKey(nodes);. If key !== lastDuplicateKey, reset duplicateCount = 0; and lastDuplicateKey = key;.
  • Build items exactly as today.
  • Inside if (items.length > 0): increment duplicateCount, compute var off = PASTE_OFFSET * duplicateCount;, call pasteItems(items, off, off).
  • Critical: after pasteItems returns, set lastDuplicateKey = selectionKey(created); BEFORE calling selectNodes(created). This way the next Ctrl+D recognizes the freshly-selected copies as the same "chain" and continues the cascade instead of resetting.
    Dependencies: Step 1

Step 5: Manual verification (no code change)

  • Ctrl+C on one rectangle, Ctrl+V three times → three rectangles at +20/+40/+60.
  • Ctrl+C again, Ctrl+V → next paste at +20 (cascade reset).
  • Ctrl+D three times → three copies at +20/+40/+60 (the chain follows the selection).
  • Click a different shape, Ctrl+D → cascade resets to +20.
  • Multi-select two shapes, Ctrl+C, Ctrl+V twice → the pair cascades as a group.
  • Drawings cascade (segment translation already in pasteItems).
  • Right-click Paste/Duplicate behave identically to keyboard shortcuts.
  • OS clipboard image paste (pasteFromOS) lands at pointer position (unchanged).
    Dependencies: Steps 1–4

Acceptance Criteria

  • Ctrl+C then Ctrl+V three times produces three distinct copies cascading at +20 / +40 / +60.
  • Ctrl+D three times on the same selection produces the same diagonal cascade.
  • A new copy() resets the cascade so the next paste starts at +20.
  • cut() (which calls copy()) also resets the cascade.
  • duplicate() on a changed selection resets the duplicate cascade.
  • Multi-select copy/paste preserves relative geometry within the group; the group as a whole cascades.
  • Drawings cascade correctly.
  • pasteFromOS() remains pointer-anchored and unaffected.
  • Right-click Paste / Duplicate exhibit the same cascade behavior as the keyboard shortcuts.

Notes

  • Single-file change. shortcuts.js and contextmenu.js already delegate to WhiteboardClipboard.paste() / .duplicate() / .copy().
  • Critical subtlety in duplicate(): after a duplicate, selectNodes(created) makes the new copies the current selection. We update lastDuplicateKey to the new copies' key (computed synchronously from created before the rAF in selectNodes runs) so the next Ctrl+D recognizes "same chain" and continues cascading instead of resetting to +20.
  • Do not modify pasteItems — only its callers compute different offsets. The drawing-segment translation from #144 lives inside pasteItems and benefits automatically from larger offsets.
  • Multi-select group geometry is preserved automatically because pasteItems applies the same (offsetX, offsetY) to every item.
  • pasteFromOS() uses _pastePos() (pointer-anchored) and never touches pasteCount — left untouched.
  • No need to reset duplicateCount inside copy() or paste(): the duplicate cascade resets on selection change, which is sufficient.
  • No new dependencies; no API surface change in the returned module object.
## Implementation Spec for Issue #145 ### Objective Replace the fixed `PASTE_OFFSET` cascade with a cumulative-offset model so that successive `paste()` and `duplicate()` calls produce a diagonal cascade (+20, +40, +60, ...) instead of stacking copies on top of each other. The change is module-local to `clipboard.js`: add small counters, multiply the base offset by the counter, and reset on the appropriate triggers (new `copy()` for paste; selection change for duplicate). `pasteFromOS()` and `pasteItems()` are not touched. ### Requirements - Pressing Ctrl+V N times after a single Ctrl+C produces N distinct copies at offsets +20, +40, ..., +N*20 from the original. - Pressing Ctrl+D N times on the same selection produces the same diagonal cascade. - Calling `copy()` (Ctrl+C, right-click Copy, Cut) resets the paste cascade so the next `paste()` lands at +20. - Calling `duplicate()` on a different selection than the previous `duplicate()` resets the duplicate cascade so the next dup lands at +20 from the new selection. - Multi-select copy/paste and copy/duplicate preserve relative geometry within the group. - Drawings cascade correctly (already handled inside `pasteItems` from #144 — it benefits automatically from larger offsets). - `pasteFromOS()` is unchanged (pointer-anchored). - Right-click "Paste" / "Duplicate" benefit automatically because `contextmenu.js` calls `WhiteboardClipboard.paste()` / `.duplicate()`. ### Files to Modify/Create - `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` — add module-scope state (`pasteCount`, `duplicateCount`, `lastDuplicateKey`); modify `copy()`, `paste()`, `duplicate()`. No other files in scope. ### Implementation Plan #### Step 1: Add cascade state at module scope Files: `clipboard.js` - Next to `var clipboard = [];` and `var PASTE_OFFSET = 20;`, add `var pasteCount = 0;`, `var duplicateCount = 0;`, `var lastDuplicateKey = '';`. - Add a private helper `selectionKey(nodes)` that returns a stable signature: map each node to `node.id()`, filter falsy, sort, `join('|')`. Sorted-string is one line, dependency-free, and order-independent. Dependencies: none #### Step 2: Reset paste cascade in `copy()` Files: `clipboard.js` - After the early-return guards and the `clipboard = []` assignment, set `pasteCount = 0;` (just before the `nodes.forEach(...)` serialization loop). - `cut()` already calls `copy()`, so the reset covers the cut path automatically. Dependencies: Step 1 #### Step 3: Cumulative offset in `paste()` Files: `clipboard.js` - After the existing `clipboard.length === 0` guard, increment `pasteCount`, compute `var off = PASTE_OFFSET * pasteCount;`, and call `pasteItems(clipboard, off, off)`. - Leave the `setTool('select')` / `setActive('select')` / `selectNodes(created)` flow untouched. Dependencies: Step 2 #### Step 4: Cumulative offset + selection-change reset in `duplicate()` Files: `clipboard.js` - After the `nodes.length === 0` guard, compute `var key = selectionKey(nodes);`. If `key !== lastDuplicateKey`, reset `duplicateCount = 0;` and `lastDuplicateKey = key;`. - Build `items` exactly as today. - Inside `if (items.length > 0)`: increment `duplicateCount`, compute `var off = PASTE_OFFSET * duplicateCount;`, call `pasteItems(items, off, off)`. - Critical: after `pasteItems` returns, set `lastDuplicateKey = selectionKey(created);` BEFORE calling `selectNodes(created)`. This way the next Ctrl+D recognizes the freshly-selected copies as the same "chain" and continues the cascade instead of resetting. Dependencies: Step 1 #### Step 5: Manual verification (no code change) - Ctrl+C on one rectangle, Ctrl+V three times → three rectangles at +20/+40/+60. - Ctrl+C again, Ctrl+V → next paste at +20 (cascade reset). - Ctrl+D three times → three copies at +20/+40/+60 (the chain follows the selection). - Click a different shape, Ctrl+D → cascade resets to +20. - Multi-select two shapes, Ctrl+C, Ctrl+V twice → the pair cascades as a group. - Drawings cascade (segment translation already in `pasteItems`). - Right-click Paste/Duplicate behave identically to keyboard shortcuts. - OS clipboard image paste (`pasteFromOS`) lands at pointer position (unchanged). Dependencies: Steps 1–4 ### Acceptance Criteria - [ ] Ctrl+C then Ctrl+V three times produces three distinct copies cascading at +20 / +40 / +60. - [ ] Ctrl+D three times on the same selection produces the same diagonal cascade. - [ ] A new `copy()` resets the cascade so the next paste starts at +20. - [ ] `cut()` (which calls `copy()`) also resets the cascade. - [ ] `duplicate()` on a changed selection resets the duplicate cascade. - [ ] Multi-select copy/paste preserves relative geometry within the group; the group as a whole cascades. - [ ] Drawings cascade correctly. - [ ] `pasteFromOS()` remains pointer-anchored and unaffected. - [ ] Right-click Paste / Duplicate exhibit the same cascade behavior as the keyboard shortcuts. ### Notes - Single-file change. `shortcuts.js` and `contextmenu.js` already delegate to `WhiteboardClipboard.paste()` / `.duplicate()` / `.copy()`. - Critical subtlety in `duplicate()`: after a duplicate, `selectNodes(created)` makes the new copies the current selection. We update `lastDuplicateKey` to the new copies' key (computed synchronously from `created` before the rAF in `selectNodes` runs) so the next Ctrl+D recognizes "same chain" and continues cascading instead of resetting to +20. - Do not modify `pasteItems` — only its callers compute different offsets. The drawing-segment translation from #144 lives inside `pasteItems` and benefits automatically from larger offsets. - Multi-select group geometry is preserved automatically because `pasteItems` applies the same `(offsetX, offsetY)` to every item. - `pasteFromOS()` uses `_pastePos()` (pointer-anchored) and never touches `pasteCount` — left untouched. - No need to reset `duplicateCount` inside `copy()` or `paste()`: the duplicate cascade resets on selection change, which is sufficient. - No new dependencies; no API surface change in the returned module object.
Author
Member

Test Results

  • cargo test --workspace --lib — PASS
  • cargo fmt --check — PASS
  • cargo clippy --workspace --all-targets -- -D warnings — PASS

Notes

This change is JS only (crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js). The Rust workspace tests do not exercise this code path; they confirm the workspace still builds, formats, and lints cleanly. Manual UI verification is required: Ctrl+C then Ctrl+V three times should produce three distinct copies cascading at +20 / +40 / +60; Ctrl+D three times on the same selection should cascade the same way; a fresh copy() resets the paste cascade; a different selection resets the duplicate cascade.

## Test Results - `cargo test --workspace --lib` — PASS - `cargo fmt --check` — PASS - `cargo clippy --workspace --all-targets -- -D warnings` — PASS ### Notes This change is JS only (`crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js`). The Rust workspace tests do not exercise this code path; they confirm the workspace still builds, formats, and lints cleanly. Manual UI verification is required: Ctrl+C then Ctrl+V three times should produce three distinct copies cascading at +20 / +40 / +60; Ctrl+D three times on the same selection should cascade the same way; a fresh `copy()` resets the paste cascade; a different selection resets the duplicate cascade.
Author
Member

Implementation Summary

Replace the fixed paste offset with a cumulative cascade so successive Ctrl+V / Ctrl+D produce a diagonal sequence instead of stacking copies on top of each other.

Changes

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js:
    • Module-scope state: pasteCount, duplicateCount, lastDuplicateKey, plus a selectionKey(nodes) helper that produces a stable, order-independent signature of a node array (sorted, pipe-joined node IDs).
    • copy() resets pasteCount = 0 so every fresh copy starts the paste cascade over (covers cut() automatically since it delegates to copy()).
    • paste() increments pasteCount then calls pasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount).
    • duplicate() resets duplicateCount only when the current selection key differs from the previous duplicate's key. After each duplicate, lastDuplicateKey is updated to the newly created copies' key BEFORE selectNodes(created) runs — so the next Ctrl+D recognizes the freshly-selected copies as the same chain and continues the cascade rather than restarting at +20.

pasteItems, pasteFromOS, cut, _pastePos, selectNodes, and hasItems are unchanged. The drawing-segment translation added in #144 lives inside pasteItems and benefits automatically from the larger offsets — multi-paste of drawings cascades correctly with no further work.

Test Results

  • cargo test --workspace --lib — PASS
  • cargo fmt --check — PASS
  • cargo clippy --workspace --all-targets -- -D warnings — PASS

Manual Verification Required

  1. Create a sticky / shape / drawing. Ctrl+C, then Ctrl+V three times — three distinct copies cascade at +20 / +40 / +60.
  2. Ctrl+C again on the original — the next Ctrl+V lands at +20 (cascade reset).
  3. Ctrl+D three times on the same selection — three copies cascade at +20 / +40 / +60.
  4. Click a different shape, then Ctrl+D — cascade resets to +20.
  5. Multi-select two objects, Ctrl+C, Ctrl+V twice — the pair cascades as a group; internal spacing preserved.
  6. Right-click Paste / Duplicate behave the same as the keyboard shortcuts (they go through the same module functions).
  7. OS clipboard image paste (Ctrl+V with no internal clipboard) lands at pointer position via pasteFromOS() — unaffected.
## Implementation Summary Replace the fixed paste offset with a cumulative cascade so successive Ctrl+V / Ctrl+D produce a diagonal sequence instead of stacking copies on top of each other. ### Changes - **`crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js`**: - Module-scope state: `pasteCount`, `duplicateCount`, `lastDuplicateKey`, plus a `selectionKey(nodes)` helper that produces a stable, order-independent signature of a node array (sorted, pipe-joined node IDs). - `copy()` resets `pasteCount = 0` so every fresh copy starts the paste cascade over (covers `cut()` automatically since it delegates to `copy()`). - `paste()` increments `pasteCount` then calls `pasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount)`. - `duplicate()` resets `duplicateCount` only when the current selection key differs from the previous duplicate's key. After each duplicate, `lastDuplicateKey` is updated to the newly created copies' key BEFORE `selectNodes(created)` runs — so the next Ctrl+D recognizes the freshly-selected copies as the same chain and continues the cascade rather than restarting at +20. `pasteItems`, `pasteFromOS`, `cut`, `_pastePos`, `selectNodes`, and `hasItems` are unchanged. The drawing-segment translation added in #144 lives inside `pasteItems` and benefits automatically from the larger offsets — multi-paste of drawings cascades correctly with no further work. ### Test Results - `cargo test --workspace --lib` — PASS - `cargo fmt --check` — PASS - `cargo clippy --workspace --all-targets -- -D warnings` — PASS ### Manual Verification Required 1. Create a sticky / shape / drawing. Ctrl+C, then Ctrl+V three times — three distinct copies cascade at +20 / +40 / +60. 2. Ctrl+C again on the original — the next Ctrl+V lands at +20 (cascade reset). 3. Ctrl+D three times on the same selection — three copies cascade at +20 / +40 / +60. 4. Click a different shape, then Ctrl+D — cascade resets to +20. 5. Multi-select two objects, Ctrl+C, Ctrl+V twice — the pair cascades as a group; internal spacing preserved. 6. Right-click Paste / Duplicate behave the same as the keyboard shortcuts (they go through the same module functions). 7. OS clipboard image paste (Ctrl+V with no internal clipboard) lands at pointer position via `pasteFromOS()` — unaffected.
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#145
No description provided.