Calendar drifts position when resized smaller #58
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#58
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?
Summary
Resizing the calendar by dragging the top-left corner anchor inward (to make
the calendar smaller) causes the calendar to move across the canvas instead
of shrinking in place. The more the user shrinks it, the further it drifts
from its original position.
Steps to reproduce
Expected
The calendar shrinks while its bottom-right corner stays anchored in place
(standard behavior for a top-left resize handle). The calendar's overall
position on the canvas should not drift.
Actual
The calendar both shrinks and translates — its position moves down and to the
right as the drag progresses. When resized to a very small size, the calendar
ends up in a visibly different location from where it started.
still an issue after setting brave shield down
Implementation Spec for Issue #58
Objective
Eliminate position drift during resize. When the user drags the top-left, top-*, or *-left anchor handles of any object's transformer, the object must shrink in place — its pivot corner (opposite the dragged anchor) must stay anchored, and the object must not translate across the canvas. This applies to all object types, not just calendar.
Requirements
node.x/node.ywere already fixed and Konva's default positioning was correct).transformhandler intools.js, currently for calendar + kanban) and to the finaltransformendconversion path (applyTransforminobjects.js, for every relevant type branch).Root cause
Konva's Transformer maintains:
and positions
node.x/node.yso the pivot corner stays at the user's expected location. The key identity is that Konva usesoldSize * scale— not our later-committednewSize— as the size.Our code then does:
For clamp (especially),
newW !== oldW * scaleX. For left-anchor or top-anchor drags, Konva shiftednode.x/node.yassuming the pre-clamp size; after we commit the clamped size, the pivot edge moves by the delta → visible drift.Files to Modify
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js— add shared helpercorrectResizeDrift, expose it, and call it inside thetransformlive-redraw handler (calendar + kanban branches).crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js— callWhiteboardTools.correctResizeDrift(...)inside everyapplyTransformtype branch that mutates size.Implementation Plan
Step 1 — Add shared helper in tools.js
Files:
tools.jsWhiteboardToolsIIFE (aftertransformeris declared), add:correctResizeDrift: correctResizeDrift.Dependencies: none.
Step 2 — Wire into the
transformlive-redraw handler in tools.jsFiles:
tools.jsoldW = bg.width(),oldH = bg.height(),expectedW = oldW * sx,expectedH = oldH * sy. Apply clamp + round as today. Afternode.scaleX(1); node.scaleY(1);, callcorrectResizeDrift(node, expectedW, expectedH, newW, newH);beforeWhiteboardCalendar.redraw(node);.oldBg = node.findOne('.bg'); oldW = oldBg.width(); oldH = oldBg.height();BEFORE mutating_kanbanStateand callingWhiteboardKanban.redraw(node)(becauserenderKanbandestroys children). After redraw, readnewBg = node.findOne('.bg'); computenewW = newBg.width(),newH = newBg.height(); callcorrectResizeDrift.Dependencies: Step 1.
Step 3 — Wire into
applyTransformbranches in objects.jsFiles:
objects.jsoldW/oldH(per table below), computeexpectedW = oldW * scaleX,expectedH = oldH * scaleY, commitnewW/newHvia existing logic, reset scale to 1, then callWhiteboardTools.correctResizeDrift(node, expectedW, expectedH, newW, newH).Per-type oldW/oldH source:
bg.width()bg.height()bg.width()bg.height()bg.radiusX()*2bg.radiusY()*2bg.radius()*2bg.outerRadius()*2bg._nominalWidthbg._nominalHeightbg.width()bg.height()bg.width()(before redraw destroys it)bg.height()intrinsic = node.getClientRect({skipTransform:true}); intrinsic.widthintrinsic.height—expectedW = intrinsic.width * scaleX,newW = intrinsic.width * finalClampedScaleexpectedW/Honce above theclsswitch from the relevant oldW/oldH source per class, capturenewW/Hinside each sub-branch, call the helper once at the end of the shape branch after the outernode.scaleX(1); node.scaleY(1);.Dependencies: Step 1.
Acceptance Criteria
top-left→ bottom-right fixedtop-center→ bottom edge fixedtop-right→ bottom-left fixedmiddle-left→ right edge fixedmiddle-right→ left edge fixed (unchanged)bottom-left→ top-right fixedbottom-center→ top edge fixed (unchanged)bottom-right→ top-left fixed (unchanged)node.x/node.y, not into position).Notes
The math, restated. Konva places
node.x = pivot.x - oldW * scaleXfor left-anchor drags. We commit size =newW, so the rendered right edge isnode.x + newW. To restoreright = pivot.x, shiftnode.x += expectedW - newWwhereexpectedW = oldW * scaleX. Symmetric for y with TOP anchors.Why bg dimensions, not getClientRect. bg is exact, matches what every branch already mutates, and its value before the mutation is the authoritative
oldW/oldH.getClientRectwould need callers to juggle before/after scale-reset and be re-derived from what we ultimately care about (bg).Mindmap is special because it doesn't reset scale to 1 — it bakes a clamped uniform scale onto the group. The intrinsic (unscaled) bbox provides
oldW/oldH;expectedW = intrinsic.w * scaleX,newW = intrinsic.w * clampedUniformScale. The helper handles the remaining drift from thescaleX vs finalScaleasymmetry.Interaction with PR #63 / PR #66. PR #63 introduced the
transformlive-redraw; this spec layers one helper call per branch on top with no change to redraw timing. PR #66's_colLayoutWeakMap is rebuilt per render and does not change here. Kanban readsoldBg.width()/height()BEFORErenderKanbandestroys children.Out of scope. Rotation drift (different math). Drift inside shape sub-bg positions (
bg.x(round(bg.x()*scaleX))) — the outer correction makes the user-visible pivot stable regardless. Drawing's pre-existing size-snap-back behaviour (separate bug).Test Results
JavaScript-only change in
tools.jsandobjects.js; Rust workspace validated for regressions.cargo check --workspacecargo test --workspace --libcargo clippy --workspace -- -D warningscargo fmt --checkNo Rust tests exercise the JavaScript whiteboard modules, so the suite confirms regression safety on the Rust side. Manual verification against a running UI is required to confirm the pivot corner stays anchored across every object type, anchor handle, and clamp edge case listed in the spec's Acceptance Criteria.
Implementation Summary
Position drift during resize is eliminated for every object type on the canvas, not just calendar. Dragging any left or top anchor shrinks the object in place — the pivot corner stays fixed — even when the new size is clamped to the type's minimum.
Files changed
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js(+35 / -2): sharedcorrectResizeDrifthelper plus wiring for the live-redraw path (calendar + kanban).crates/hero_whiteboard_ui/static/web/js/whiteboard/objects.js(+169 / -12): wiring inside everyapplyTransformtype branch that mutates size.Root cause
Konva's Transformer sets
node.x/node.yassuming the visible size equalsoldSize * scale. When our code rounds or clamps the committed size tonewSize, left/top-anchored drags shift the pivot by the delta, producing visible drift — most obvious on calendar, which clamps to a per-view minimum, but present to some degree anywhere size is rounded or clamped.Fix
A single helper in
tools.js:Applied at two call sites:
tools.jstransformlive-redraw: calendar and kanban branches capture old bg dimensions before mutating / redrawing, computeexpected = old * scale, and call the helper with the new bg dimensions after redraw.objects.jsapplyTransform: every type branch that mutates size (sticky, shape with eachcls, frame, document, image, webframe, emoji, kanban, calendar, mindmap) capturesoldW/oldH, computes expected/new, and calls the helper afternode.scaleX(1); node.scaleY(1);.textanddrawing/generic are skipped because they do not meaningfully resize.Mindmap is handled specially because it does not reset scale to 1 — it bakes a clamped uniform scale onto the group. The helper is called with the intrinsic bbox (via
getClientRect({skipTransform: true})) multiplied byscaleX/scaleY(expected) vs. the clamped final scale (new).In addition, the shape
elsefallback was tightened toelse if (cls === 'Rect')so an unknown shape class falls through without a bogus drift correction; the six known shape classes (Rect, Ellipse, RegularPolygon, Star, Path, Line) are all explicitly handled.Preserved behaviour
transformer.forceUpdate()call; the helper is an additional call after each branch's scale reset._colLayoutWeakMap): rebuilt byrenderKanbanas before; we readoldBg.width()/height()BEFORE callingWhiteboardKanban.redraw(node)so we don't dereference a destroyed node.transformer.getActiveAnchor()which returns the same anchor for every selected node, so each gets its own correction simultaneously.Test results
cargo check --workspace: passcargo test --workspace --lib: passcargo clippy --workspace -- -D warnings: passcargo fmt --check: passManual QA required: verify each object type's pivot corner stays put when the top-left anchor is dragged inward, including at the clamp minimum; test the remaining seven anchor handles; confirm no regression on rotation and on multi-select transforms.
Pull request opened: #68
This PR implements the changes discussed in this issue.