Pasting / duplicating a freehand drawing places the copy on top of the original #144

Open
opened 2026-05-05 09:59:06 +00:00 by AhmedHanafy725 · 3 comments
Member

Bug

Copying a freehand drawing and pasting it (Ctrl+V) — or duplicating it (Ctrl+D) — drops the new drawing at the exact same position as the original. Visually nothing appears to happen, so the user assumes the action did not work. All other object types paste with the standard 20px offset; only freehand drawings have this regression.

Reproduction

  1. Pick the Draw tool (D) and scribble a freehand stroke.
  2. Switch back to Select (V), click the drawing.
  3. Press Ctrl+C then Ctrl+V (or Ctrl+D to duplicate).
  4. The new drawing is created on top of the original — the toolbar/selection handles look like only one drawing is selected, but there are actually two stacked at the same coordinates.
  5. Drag the selection — a duplicate slides out from underneath, confirming the paste did happen but at offset (0, 0).
  6. Repeat with a sticky / text / shape / document / image — each correctly appears 20px down-right of the original.

Root cause

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js line 51 (pasteItems) bumps data.x / data.y by PASTE_OFFSET (20px) for every cloned item.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js line 349 (serializeForServer, drawing branch) writes data.segments as absolute world coordinates by pre-adding node.x() + node.y() to each local point.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/app.js line 483 (renderSingleObject case 'drawing') only passes data.segments + stroke to createDrawing; it does NOT pass obj.x / obj.y.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js line 2108 (createDrawing) derives the group's x / y from minX / minY of the absolute segments, ignoring any caller-supplied position.

Net effect: the bumped data.x / data.y from pasteItems are silently discarded for drawings, because the segments still carry the original world coordinates.

Other object types are unaffected: their content lives in group-local coords and the group origin is taken from data.x / data.y.

Fix scope

Minimal: inside pasteItems, when data.type === 'drawing' and data.segments (or legacy data.points) is present, translate every point by (offsetX, offsetY) before handing the cloned data to renderSingleObject. Bumping data.x / data.y for drawings can stay (harmless — createDrawing ignores it) but the segments themselves are what determine position.

Do not change createDrawing or the drawing serialization — those are touched by many other code paths (server load, collab sync, undo/redo restore) and any change there would regress those paths.

Affected files

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

Acceptance criteria

  • Copying a freehand drawing and pasting (Ctrl+V) produces a new drawing offset 20px down-right of the original — same as every other object type.
  • Ctrl+D duplicate on a drawing offsets the same way.
  • Cut + paste on a drawing also offsets correctly.
  • Multi-select copy with mixed content (drawing + sticky + shape) offsets every item by 20px relative to its original position — the relative geometry of the group is preserved.
  • Pasting a drawing does NOT mutate the clipboard — Ctrl+V twice in a row produces two copies offset 20 and 40 from the original (the existing JSON.parse(JSON.stringify(original)) already deep-clones, but verify this still holds for the segment translation).
  • Server load, undo/redo, and collab sync of drawings continue to render at their original world coordinates (no regression on those paths).
## Bug Copying a freehand drawing and pasting it (Ctrl+V) — or duplicating it (Ctrl+D) — drops the new drawing at the exact same position as the original. Visually nothing appears to happen, so the user assumes the action did not work. All other object types paste with the standard 20px offset; only freehand drawings have this regression. ## Reproduction 1. Pick the Draw tool (D) and scribble a freehand stroke. 2. Switch back to Select (V), click the drawing. 3. Press Ctrl+C then Ctrl+V (or Ctrl+D to duplicate). 4. The new drawing is created on top of the original — the toolbar/selection handles look like only one drawing is selected, but there are actually two stacked at the same coordinates. 5. Drag the selection — a duplicate slides out from underneath, confirming the paste did happen but at offset (0, 0). 6. Repeat with a sticky / text / shape / document / image — each correctly appears 20px down-right of the original. ## Root cause - `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` line 51 (`pasteItems`) bumps `data.x` / `data.y` by `PASTE_OFFSET` (20px) for every cloned item. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` line 349 (`serializeForServer`, drawing branch) writes `data.segments` as **absolute world coordinates** by pre-adding `node.x()` + `node.y()` to each local point. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/app.js` line 483 (`renderSingleObject` case `'drawing'`) only passes `data.segments` + stroke to `createDrawing`; it does NOT pass `obj.x` / `obj.y`. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js` line 2108 (`createDrawing`) derives the group's `x` / `y` from `minX` / `minY` of the absolute segments, ignoring any caller-supplied position. Net effect: the bumped `data.x` / `data.y` from `pasteItems` are silently discarded for drawings, because the segments still carry the original world coordinates. Other object types are unaffected: their content lives in group-local coords and the group origin is taken from `data.x` / `data.y`. ## Fix scope Minimal: inside `pasteItems`, when `data.type === 'drawing'` and `data.segments` (or legacy `data.points`) is present, translate every point by `(offsetX, offsetY)` before handing the cloned data to `renderSingleObject`. Bumping `data.x` / `data.y` for drawings can stay (harmless — `createDrawing` ignores it) but the segments themselves are what determine position. Do not change `createDrawing` or the drawing serialization — those are touched by many other code paths (server load, collab sync, undo/redo restore) and any change there would regress those paths. ## Affected files - `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` — the only file in scope. Modify `pasteItems`. ## Acceptance criteria - [ ] Copying a freehand drawing and pasting (Ctrl+V) produces a new drawing offset 20px down-right of the original — same as every other object type. - [ ] Ctrl+D duplicate on a drawing offsets the same way. - [ ] Cut + paste on a drawing also offsets correctly. - [ ] Multi-select copy with mixed content (drawing + sticky + shape) offsets every item by 20px relative to its original position — the relative geometry of the group is preserved. - [ ] Pasting a drawing does NOT mutate the clipboard — Ctrl+V twice in a row produces two copies offset 20 and 40 from the original (the existing `JSON.parse(JSON.stringify(original))` already deep-clones, but verify this still holds for the segment translation). - [ ] Server load, undo/redo, and collab sync of drawings continue to render at their original world coordinates (no regression on those paths).
Author
Member

Implementation Spec for Issue #144

Objective

Fix freehand-drawing copy/paste/duplicate so the new drawing appears offset by 20px from the original instead of landing exactly on top of it. The bug exists because drawings serialize their stroke data as absolute world coordinates inside data.data.segments (legacy: data.data.points), so the existing data.x/data.y bump in pasteItems is silently discarded by the drawing render path. The fix is to translate the cloned segment points by the paste offset inside pasteItems, drawing-only, leaving the server-load / collab-sync / undo-redo restore paths untouched.

Requirements

  • The paste offset (PASTE_OFFSET = 20) must visibly translate the new drawing on both paste() and duplicate().
  • Translation must apply to the deep clone created by JSON.parse(JSON.stringify(original)), never to the clipboard original (so repeated pastes from the same clipboard entry don't compound the offset).
  • Both data shapes must be handled:
    • New: data.data.segments is Array<Array<number>> — a list of segments, each a flat [x0, y0, x1, y1, ...].
    • Legacy: data.data.points is a single flat Array<number>.
  • The existing data.x / data.y bump must be preserved (harmless and consistent with other types).
  • Only data.type === 'drawing' gets the segment translation. Confirmed in serializeForServer that no other type pre-adds node.x()/node.y() to its data payload.
  • No changes to createDrawing (objects.js), serializeForServer (sync.js), or renderSingleObject (app.js) — those paths are shared with server load, collab sync, and undo/redo restore.

Files to Modify/Create

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js — modify pasteItems only.

Implementation Plan

Step 1: Translate drawing segments inside pasteItems

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

  • Inside pasteItems, after the JSON.parse(JSON.stringify(original)) deep clone and after the existing data.x / data.y bump, add a drawing-only branch:
    • Guard on data.type === 'drawing' and presence of data.data.
    • If data.data.segments is a non-empty array, iterate each segment and add offsetX to even indices and offsetY to odd indices in place on the clone.
    • Else if data.data.points is a non-empty array (legacy single segment), apply the same in-place translation to that flat array.
    • Skip silently if neither is present or if a segment has odd length / length < 4 — leave it unchanged so it round-trips without throwing.
  • Keep all other lines (delete data.id, nextTempId, renderSingleObject, getObject) exactly as-is.
    Dependencies: none

Acceptance Criteria

  • Drawing a freehand stroke, selecting it, and pressing Ctrl+C / Ctrl+V produces a visible duplicate offset by 20px in both x and y from the original.
  • Selecting a drawing and pressing Ctrl+D (duplicate) produces a duplicate offset by 20px in both x and y.
  • Pasting the same clipboard entry twice in a row produces two duplicates each offset 20px from the original (not 20 and 40) — the clipboard original is not mutated.
  • Multi-segment drawings (created by lifting and re-pressing the pen mid-stroke) translate every segment, not just the first.
  • Legacy drawings stored with data.data.points (single flat array) still paste with the correct offset.
  • Copying / pasting non-drawing objects (text, sticky, shape, frame, image, emoji, webframe, mindmap, kanban, calendar, group) continues to work and is offset by 20px exactly as before.
  • Reloading the board, receiving a drawing via WebSocket collab sync, and undo/redo of a drawing all continue to render the drawing in its correct (un-offset) position — confirming serializeForServer, renderSingleObject, and createDrawing were not regressed.

Notes

  • Clipboard isolation: JSON.parse(JSON.stringify(original)) already produces fully independent arrays for segments and points, so in-place mutation on the clone is safe and will not contaminate the clipboard original. This is essential for repeated pastes.
  • Multi-segment shape: serializeForServer (sync.js line 349-362) builds data.segments as Array<Array<number>>, one inner array per pen-down stroke. The translation must loop over the outer array and translate each inner flat array.
  • Legacy data.points: app.js line 487-488 still accepts a single flat data.points array as a fallback for older saved drawings. The fix handles both shapes.
  • Drawing-only scope: confirmed in serializeForServer that no other type pre-adds node.x() / node.y() to its data payload — kanban, calendar, mindmap, group, etc. all rely on top-level data.x / data.y. The new translation must be gated strictly on data.type === 'drawing'.
  • Untouched paths: createDrawing derives the Konva group's x/y from minX/minY of the supplied (absolute) points, so translating the points before calling renderSingleObject will naturally cause the group to land at the offset position with the same internal local-point layout.
  • Defensive coding: guard against data.data being missing and against malformed segments (odd length, length < 4); skip rather than throw so a single corrupt segment can't break the entire paste.
## Implementation Spec for Issue #144 ### Objective Fix freehand-drawing copy/paste/duplicate so the new drawing appears offset by 20px from the original instead of landing exactly on top of it. The bug exists because drawings serialize their stroke data as absolute world coordinates inside `data.data.segments` (legacy: `data.data.points`), so the existing `data.x`/`data.y` bump in `pasteItems` is silently discarded by the drawing render path. The fix is to translate the cloned segment points by the paste offset inside `pasteItems`, drawing-only, leaving the server-load / collab-sync / undo-redo restore paths untouched. ### Requirements - The paste offset (`PASTE_OFFSET = 20`) must visibly translate the new drawing on both `paste()` and `duplicate()`. - Translation must apply to the deep clone created by `JSON.parse(JSON.stringify(original))`, never to the clipboard original (so repeated pastes from the same clipboard entry don't compound the offset). - Both data shapes must be handled: - New: `data.data.segments` is `Array<Array<number>>` — a list of segments, each a flat `[x0, y0, x1, y1, ...]`. - Legacy: `data.data.points` is a single flat `Array<number>`. - The existing `data.x` / `data.y` bump must be preserved (harmless and consistent with other types). - Only `data.type === 'drawing'` gets the segment translation. Confirmed in `serializeForServer` that no other type pre-adds `node.x()`/`node.y()` to its `data` payload. - No changes to `createDrawing` (`objects.js`), `serializeForServer` (`sync.js`), or `renderSingleObject` (`app.js`) — those paths are shared with server load, collab sync, and undo/redo restore. ### Files to Modify/Create - `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` — modify `pasteItems` only. ### Implementation Plan #### Step 1: Translate drawing segments inside `pasteItems` Files: `crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js` - Inside `pasteItems`, after the `JSON.parse(JSON.stringify(original))` deep clone and after the existing `data.x` / `data.y` bump, add a drawing-only branch: - Guard on `data.type === 'drawing'` and presence of `data.data`. - If `data.data.segments` is a non-empty array, iterate each segment and add `offsetX` to even indices and `offsetY` to odd indices in place on the clone. - Else if `data.data.points` is a non-empty array (legacy single segment), apply the same in-place translation to that flat array. - Skip silently if neither is present or if a segment has odd length / length < 4 — leave it unchanged so it round-trips without throwing. - Keep all other lines (`delete data.id`, `nextTempId`, `renderSingleObject`, `getObject`) exactly as-is. Dependencies: none ### Acceptance Criteria - [ ] Drawing a freehand stroke, selecting it, and pressing Ctrl+C / Ctrl+V produces a visible duplicate offset by 20px in both x and y from the original. - [ ] Selecting a drawing and pressing Ctrl+D (duplicate) produces a duplicate offset by 20px in both x and y. - [ ] Pasting the same clipboard entry twice in a row produces two duplicates each offset 20px from the original (not 20 and 40) — the clipboard original is not mutated. - [ ] Multi-segment drawings (created by lifting and re-pressing the pen mid-stroke) translate every segment, not just the first. - [ ] Legacy drawings stored with `data.data.points` (single flat array) still paste with the correct offset. - [ ] Copying / pasting non-drawing objects (text, sticky, shape, frame, image, emoji, webframe, mindmap, kanban, calendar, group) continues to work and is offset by 20px exactly as before. - [ ] Reloading the board, receiving a drawing via WebSocket collab sync, and undo/redo of a drawing all continue to render the drawing in its correct (un-offset) position — confirming `serializeForServer`, `renderSingleObject`, and `createDrawing` were not regressed. ### Notes - Clipboard isolation: `JSON.parse(JSON.stringify(original))` already produces fully independent arrays for `segments` and `points`, so in-place mutation on the clone is safe and will not contaminate the clipboard original. This is essential for repeated pastes. - Multi-segment shape: `serializeForServer` (sync.js line 349-362) builds `data.segments` as `Array<Array<number>>`, one inner array per pen-down stroke. The translation must loop over the outer array and translate each inner flat array. - Legacy `data.points`: `app.js` line 487-488 still accepts a single flat `data.points` array as a fallback for older saved drawings. The fix handles both shapes. - Drawing-only scope: confirmed in `serializeForServer` that no other type pre-adds `node.x()` / `node.y()` to its `data` payload — `kanban`, `calendar`, `mindmap`, `group`, etc. all rely on top-level `data.x` / `data.y`. The new translation must be gated strictly on `data.type === 'drawing'`. - Untouched paths: `createDrawing` derives the Konva group's `x`/`y` from `minX`/`minY` of the supplied (absolute) points, so translating the points before calling `renderSingleObject` will naturally cause the group to land at the offset position with the same internal local-point layout. - Defensive coding: guard against `data.data` being missing and against malformed segments (odd length, length < 4); skip rather than throw so a single corrupt segment can't break the entire paste.
Author
Member

Test Results

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

Notes

This fix 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 / Ctrl+V on a freehand drawing should produce a duplicate offset 20px down-right; Ctrl+D duplicates the same way; non-drawing types continue to paste with the existing 20px offset.

## Test Results - `cargo test --workspace --lib` — PASS - `cargo fmt --check` — PASS - `cargo clippy --workspace --all-targets -- -D warnings` — PASS ### Notes This fix 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 / Ctrl+V on a freehand drawing should produce a duplicate offset 20px down-right; Ctrl+D duplicates the same way; non-drawing types continue to paste with the existing 20px offset.
Author
Member

Implementation Summary

Fix for freehand drawings landing on top of the original when copy/pasted or duplicated.

Changes

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js — extended pasteItems with a drawing-only branch that translates the cloned segment points by (offsetX, offsetY). Handles both data.data.segments (multi-segment, current format) and legacy data.data.points (single flat array). Defensive: skips malformed segments (odd length or length < 4) so a single corrupt segment cannot break the whole paste. Mutation happens on the deep clone produced by JSON.parse(JSON.stringify(original)), so the clipboard original is never touched and repeated pastes from the same clipboard entry each land at original + 20.

No changes to createDrawing (objects.js), serializeForServer (sync.js), or renderSingleObject (app.js) — those paths are shared with server load, collab sync, and undo/redo restore and must not regress. The existing top-level data.x / data.y bump remains in place (harmless for drawings, required for every other type).

Test Results

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

Manual Verification Required

The fix is JS only and the workspace has no JS test harness. Please verify:

  1. Draw a freehand stroke, select it, Ctrl+C / Ctrl+V — the new drawing appears 20px down-right of the original (not on top).
  2. Ctrl+D on a drawing duplicates the same way.
  3. Ctrl+V twice in a row produces two duplicates each at original + 20 (not 20 then 40) — the clipboard original must not be mutated.
  4. Multi-segment drawings (lift the pen mid-stroke, draw another segment) translate every segment, not just the first.
  5. Copy/paste of non-drawing objects (text, sticky, shape, frame, image, emoji, webframe, mindmap, kanban, calendar, group) continues to offset by 20px exactly as before.
  6. Reload the board, send a drawing via collab, undo/redo a drawing — all render at the correct (un-offset) original position.
## Implementation Summary Fix for freehand drawings landing on top of the original when copy/pasted or duplicated. ### Changes - **`crates/hero_whiteboard_ui/static/web/js/whiteboard/clipboard.js`** — extended `pasteItems` with a drawing-only branch that translates the cloned segment points by `(offsetX, offsetY)`. Handles both `data.data.segments` (multi-segment, current format) and legacy `data.data.points` (single flat array). Defensive: skips malformed segments (odd length or length < 4) so a single corrupt segment cannot break the whole paste. Mutation happens on the deep clone produced by `JSON.parse(JSON.stringify(original))`, so the clipboard original is never touched and repeated pastes from the same clipboard entry each land at original + 20. No changes to `createDrawing` (`objects.js`), `serializeForServer` (`sync.js`), or `renderSingleObject` (`app.js`) — those paths are shared with server load, collab sync, and undo/redo restore and must not regress. The existing top-level `data.x` / `data.y` bump remains in place (harmless for drawings, required for every other type). ### Test Results - `cargo test --workspace --lib` — PASS - `cargo fmt --check` — PASS - `cargo clippy --workspace --all-targets -- -D warnings` — PASS ### Manual Verification Required The fix is JS only and the workspace has no JS test harness. Please verify: 1. Draw a freehand stroke, select it, Ctrl+C / Ctrl+V — the new drawing appears 20px down-right of the original (not on top). 2. Ctrl+D on a drawing duplicates the same way. 3. Ctrl+V twice in a row produces two duplicates each at original + 20 (not 20 then 40) — the clipboard original must not be mutated. 4. Multi-segment drawings (lift the pen mid-stroke, draw another segment) translate every segment, not just the first. 5. Copy/paste of non-drawing objects (text, sticky, shape, frame, image, emoji, webframe, mindmap, kanban, calendar, group) continues to offset by 20px exactly as before. 6. Reload the board, send a drawing via collab, undo/redo a drawing — all render at the correct (un-offset) original position.
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#144
No description provided.