Pasting twice in a row stacks copies on top of each other (use cumulative offset) #145
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#145
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?
Bug / UX gap
Every
paste()andduplicate()call uses a fixed 20px offset from the original. So Ctrl+V Ctrl+V Ctrl+V produces three copies all stacked atoriginal + 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
Desired behavior
original + (PASTE_OFFSET, PASTE_OFFSET)(today's behavior).+ PASTE_OFFSETfurther down-right (cumulative cascade).copy()resets the cascade so the next paste starts at offset 1 again.Affected file
crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js— only file in scope.Implementation sketch
pasteCount(integer) at module scope alongside the existingclipboardarray.copy()resetspasteCount = 0so a fresh copy starts the cascade over.paste()incrementspasteCount, then callspasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount).duplicate()does the same against its captureditemssnapshot — 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 separateduplicateCountand reset it wheneverWhiteboardTools.getTransformer().nodes()differs from the last duplicate target).offsetX/offsetYfrompasteItems, so it benefits automatically without any further change.Acceptance criteria
copy()(or selecting a different object and copying it), the next paste starts the cascade over at +20.pasteFromOS()(that path uses_pastePos()and is independent of the cascade counter).Out of scope
Implementation Spec for Issue #145
Objective
Replace the fixed
PASTE_OFFSETcascade with a cumulative-offset model so that successivepaste()andduplicate()calls produce a diagonal cascade (+20, +40, +60, ...) instead of stacking copies on top of each other. The change is module-local toclipboard.js: add small counters, multiply the base offset by the counter, and reset on the appropriate triggers (newcopy()for paste; selection change for duplicate).pasteFromOS()andpasteItems()are not touched.Requirements
copy()(Ctrl+C, right-click Copy, Cut) resets the paste cascade so the nextpaste()lands at +20.duplicate()on a different selection than the previousduplicate()resets the duplicate cascade so the next dup lands at +20 from the new selection.pasteItemsfrom #144 — it benefits automatically from larger offsets).pasteFromOS()is unchanged (pointer-anchored).contextmenu.jscallsWhiteboardClipboard.paste()/.duplicate().Files to Modify/Create
crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js— add module-scope state (pasteCount,duplicateCount,lastDuplicateKey); modifycopy(),paste(),duplicate(). No other files in scope.Implementation Plan
Step 1: Add cascade state at module scope
Files:
clipboard.jsvar clipboard = [];andvar PASTE_OFFSET = 20;, addvar pasteCount = 0;,var duplicateCount = 0;,var lastDuplicateKey = '';.selectionKey(nodes)that returns a stable signature: map each node tonode.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.jsclipboard = []assignment, setpasteCount = 0;(just before thenodes.forEach(...)serialization loop).cut()already callscopy(), so the reset covers the cut path automatically.Dependencies: Step 1
Step 3: Cumulative offset in
paste()Files:
clipboard.jsclipboard.length === 0guard, incrementpasteCount, computevar off = PASTE_OFFSET * pasteCount;, and callpasteItems(clipboard, off, off).setTool('select')/setActive('select')/selectNodes(created)flow untouched.Dependencies: Step 2
Step 4: Cumulative offset + selection-change reset in
duplicate()Files:
clipboard.jsnodes.length === 0guard, computevar key = selectionKey(nodes);. Ifkey !== lastDuplicateKey, resetduplicateCount = 0;andlastDuplicateKey = key;.itemsexactly as today.if (items.length > 0): incrementduplicateCount, computevar off = PASTE_OFFSET * duplicateCount;, callpasteItems(items, off, off).pasteItemsreturns, setlastDuplicateKey = selectionKey(created);BEFORE callingselectNodes(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)
pasteItems).pasteFromOS) lands at pointer position (unchanged).Dependencies: Steps 1–4
Acceptance Criteria
copy()resets the cascade so the next paste starts at +20.cut()(which callscopy()) also resets the cascade.duplicate()on a changed selection resets the duplicate cascade.pasteFromOS()remains pointer-anchored and unaffected.Notes
shortcuts.jsandcontextmenu.jsalready delegate toWhiteboardClipboard.paste()/.duplicate()/.copy().duplicate(): after a duplicate,selectNodes(created)makes the new copies the current selection. We updatelastDuplicateKeyto the new copies' key (computed synchronously fromcreatedbefore the rAF inselectNodesruns) so the next Ctrl+D recognizes "same chain" and continues cascading instead of resetting to +20.pasteItems— only its callers compute different offsets. The drawing-segment translation from #144 lives insidepasteItemsand benefits automatically from larger offsets.pasteItemsapplies the same(offsetX, offsetY)to every item.pasteFromOS()uses_pastePos()(pointer-anchored) and never touchespasteCount— left untouched.duplicateCountinsidecopy()orpaste(): the duplicate cascade resets on selection change, which is sufficient.Test Results
cargo test --workspace --lib— PASScargo fmt --check— PASScargo clippy --workspace --all-targets -- -D warnings— PASSNotes
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 freshcopy()resets the paste cascade; a different selection resets the duplicate cascade.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:pasteCount,duplicateCount,lastDuplicateKey, plus aselectionKey(nodes)helper that produces a stable, order-independent signature of a node array (sorted, pipe-joined node IDs).copy()resetspasteCount = 0so every fresh copy starts the paste cascade over (coverscut()automatically since it delegates tocopy()).paste()incrementspasteCountthen callspasteItems(clipboard, PASTE_OFFSET * pasteCount, PASTE_OFFSET * pasteCount).duplicate()resetsduplicateCountonly when the current selection key differs from the previous duplicate's key. After each duplicate,lastDuplicateKeyis updated to the newly created copies' key BEFOREselectNodes(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, andhasItemsare unchanged. The drawing-segment translation added in #144 lives insidepasteItemsand benefits automatically from the larger offsets — multi-paste of drawings cascades correctly with no further work.Test Results
cargo test --workspace --lib— PASScargo fmt --check— PASScargo clippy --workspace --all-targets -- -D warnings— PASSManual Verification Required
pasteFromOS()— unaffected.