Rubber-band selection does not auto-pan the canvas at viewport edges #146
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#146
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
In select mode, starting a rubber-band selection (mousedown on empty canvas + drag) and moving the cursor toward any viewport edge stops the selection at the edge. There is no way to extend the selection to objects that are off-screen without first releasing, panning the canvas, then starting a new selection.
Industry convention (Figma, Miro, Excalidraw, FigJam) is edge-pan-while-selecting: when the cursor enters a margin near any viewport edge, the canvas auto-pans at a speed proportional to how deep into the margin the cursor is, and the selection rectangle grows to match. Releasing the mouse stops the pan and finalizes the selection.
Reproduction
Root cause
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js:onMouseDown(line 491–496) setsisSelecting = trueand snapshotsselStartin world coordinates.onMouseMove(line 593–599) updates the selection rect'sx/y/w/hfrom the current cursor position. Nothing checks whether the cursor is near a viewport edge, and nothing pans the stage.Fix scope
Add an edge-pan loop while
currentTool === 'select' && isSelectingis true:onMouseMovewhile rubber-banding, capture the cursor's screen-space position relative to the stage container's bounding rect.EDGE_MARGINpixels from any edge; otherwise scaled linearly from 0 at the margin boundary toMAX_PAN_SPEED(px/frame) at the very edge (or beyond — useMath.max(0, depth)so the speed clamps when the cursor moves past the canvas).requestAnimationFrameloop that translates the stage by(-vx, -vy)per frame and updates the selection rect's bottom-right corner to follow the cursor (which is in screen-space; convert back to world coords using the post-pan stage transform).isSelectingflips to false (mouseup), (b) the cursor leaves all margins, or (c) the user switches tool / cancels.onMouseUp(around line 621) runs unchanged — the selection rect's world-space rect is already correct because the pan loop kept it synced.Affected files
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js— only file in scope. Add edge-pan logic to the rubber-band path.canvas.js,app.js, or any other module.)Acceptance criteria
requestAnimationFrame.Out of scope
Implementation Spec for Issue #146
Objective
Add edge-pan auto-scroll to the rubber-band selection in select mode so that when the cursor approaches a viewport edge during a marquee drag, the canvas pans at a speed proportional to how deep the cursor sits inside an edge margin, and the selection rectangle continues to grow toward the cursor's world-space position. This brings the whiteboard's marquee behavior in line with Figma/Miro/Excalidraw and removes the "release, pan, re-select" workaround needed to capture off-screen objects.
Requirements
currentTool === 'select' && isSelecting === true.EDGE_MARGIN(screen pixels), capped atMAX_PAN_SPEED(px per frame at ~60fps).requestAnimationFrameloop drives the pan; it must self-cancel when no axis has non-zero velocity, whenisSelectingis cleared, when the tool changes, or on mouseup.stage.position(...)+WhiteboardCanvas.drawGrid()) so connector layer, grid, sticky overlays, and viewport listeners stay in sync.Files to Modify/Create
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js— single-file change inside theWhiteboardToolsIIFE.canvas.jsis consulted read-only; the existing pan pattern (stage.position({x,y})thenWhiteboardCanvas.drawGrid()— used bysetupWheelZoomat canvas.js:236-242 and the middle-mouse handler at tools.js:386-391) is reused as-is.drawGrid()already triggersupdateViewport()and notifies viewport listeners.Implementation Plan
Step 1: Module-level constants and state
Files:
tools.jsselectionRect/isSelecting/selStartdeclarations:var EDGE_MARGIN = 40;(screen px)var MAX_PAN_SPEED = 18;(px/frame at 60fps ≈ 1080 px/s)var panRafId = null;var lastClientX = 0;,var lastClientY = 0;(cached DOMMouseEvent.clientX/Yso the rAF loop has fresh cursor data even when the mouse is held still)Dependencies: none
Step 2: Cache cursor screen position on every mousemove during rubber-band
Files:
tools.js(onMouseMove, around line 589)onMouseMove, whencurrentTool === 'select' && isSelecting, reade.evt.clientX/clientYand cache intolastClientX/Y. Konva exposes the underlying DOM event one.evt.select && isSelectingbranch, after theselectionRect.setAttrs({...})+batchDraw()lines, callmaybeStartEdgePan().Dependencies: Step 1
Step 3: Velocity computation + rAF pan loop
Files:
tools.js(new helpers nearonMouseMove/onMouseUp)computeEdgePanVelocity(stageBox)returns{vx, vy}:vxis positive whenlastClientX > stageBox.right - EDGE_MARGIN, negative whenlastClientX < stageBox.left + EDGE_MARGIN. Magnitude(depth / EDGE_MARGIN) * MAX_PAN_SPEED, clamped to[0, MAX_PAN_SPEED]. Cursor outside the container clamps toMAX_PAN_SPEED. Sign matches the wheel-pan convention sostage.x() - vxslides off-screen-right content into view.vysymmetric for top/bottom.maybeStartEdgePan():if (currentTool !== 'select' || !isSelecting || panRafId != null) return;{vx, vy}; if both zero, do nothing.panRafId = requestAnimationFrame(edgePanTick);edgePanTick():panRafId = null;first.if (currentTool !== 'select' || !isSelecting) return;{vx, vy}. If both zero, stop (do not reschedule).stage.position({ x: stage.x() - vx, y: stage.y() - vy }); WhiteboardCanvas.drawGrid();worldX = (lastClientX - stageBox.left - stage.x()) / stage.scaleX();(and Y analogously). After-pan transform.onMouseMovebranch andbatchDraw().panRafId = requestAnimationFrame(edgePanTick);stopEdgePan(): cancel + null the handle.Dependencies: Steps 1-2
Step 4: Wire stop conditions
Files:
tools.jsonMouseUpinside theselect && isSelectingblock, callstopEdgePan()immediately beforeisSelecting = false;.setToolat the top, callstopEdgePan()unconditionally — covers tool change mid-drag.Dependencies: Step 3
Acceptance Criteria
MAX_PAN_SPEED.drawGrid()->updateViewport()).Notes
canvas.jsdoes not expose apanBy(dx, dy)helper. The two existing imperative-pan call sites both directly setstage.position({x, y})and callWhiteboardCanvas.drawGrid(). Reuse that pattern verbatim —drawGrid()triggersupdateViewport(), which fires every registered viewport listener. Do NOT introduce a newpanByexport just for this feature.stage.x()is the world origin's screen-pixel offset. To make off-screen-right content slide into view when the cursor reaches the right margin, decreasestage.x()(applystage.x() - vxwith positivevx).stage.getPointerPosition()is event-driven, not poll-able. We must cache the DOMMouseEvent.clientX/Yon everyonMouseMoveand convert to world coords inside the rAF using the post-pan transform:worldX = (clientX - stageBox.left - stage.x()) / stage.scaleX().panRafId; null when not running. Set it just before scheduling and clear it as the first action in the callback. Cancel viastopEdgePan()inonMouseUpandsetTool; the in-callback guard covers other edge cases. Theif (panRafId != null)guard inmaybeStartEdgePanenforces no chained rAFs.requestAnimationFramecall sites intools.js(eraser-related) are one-shot scheduling for unrelated work — no contention.selectionRectis local UI. The finalized world-spaceselectionRect.getClientRect()inonMouseUpalready captures the grown rect correctly (Konva computes it from the rect's own world attrs).depth / EDGE_MARGIN * MAX_PAN_SPEED) for a first pass — matches Excalidraw. Quadratic curve is a follow-up polish item, explicitly out of scope per the issue.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/tools.js). The Rust workspace tests do not exercise this code path; they confirm the workspace still builds, formats, and lints cleanly. Manual UI verification: in select mode, mousedown on empty canvas and drag toward any viewport edge — the canvas should auto-pan and the rubber-band rectangle should grow to reach off-screen objects. Releasing the mouse should finalize the selection over the full grown rect.Implementation Summary
Added edge-pan auto-scroll to rubber-band selection so dragging the cursor toward any viewport edge pans the canvas and grows the selection rectangle to reach off-screen objects (Figma/Miro/Excalidraw convention).
Changes
crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js— added 5 module-scope vars and 4 helpers, plus three small wiring touches:EDGE_MARGIN = 40(screen px),MAX_PAN_SPEED = 18(px/frame at 60fps).panRafId(single rAF handle),lastClientX/lastClientY(cached DOMMouseEventcoords so the rAF loop has fresh cursor data when the mouse is held still).computeEdgePanVelocity(stageBox)— linear ramp from 0 at margin boundary to MAX_PAN_SPEED at the edge, clamped at the edge for cursors past the container.maybeStartEdgePan()— guarded scheduling: only fires whencurrentTool === 'select' && isSelecting && panRafId == null.edgePanTick()— pans the stage viastage.position(...)+WhiteboardCanvas.drawGrid()(matches the existing wheel-zoom and middle-mouse pan paths), recomputes the cursor's world-space position from the post-pan transform, updates the selection rect, and reschedules.stopEdgePan()— cancels the rAF and nulls the handle.onMouseMove(select branch): caches the cursor's screen position frome.evtand callsmaybeStartEdgePan().onMouseUp(select branch): callsstopEdgePan()before clearingisSelecting.setTool: callsstopEdgePan()unconditionally at the top so a tool change mid-drag cleans up.No new exported helpers, no changes to
canvas.js, no changes to collab/sync. Pan reuses the existing primitive so connectors / grid / webframe overlays stay in sync. The in-callbackif (currentTool !== 'select' || !isSelecting) return;guard covers any edge case (window blur dropping the mouseup, etc.).Test Results
cargo test --workspace --lib— PASScargo fmt --check— PASScargo clippy --workspace --all-targets -- -D warnings— PASSManual Verification Required