Rubber-band selection does not auto-pan the canvas at viewport edges #146

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

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

  1. Place objects spread across a wide area — some inside the current viewport, some off-screen to the right (or any direction).
  2. Switch to Select mode (V).
  3. Mousedown on empty canvas inside the viewport, drag toward the right edge until the cursor hits the edge of the canvas region.
  4. Observed: the rubber-band rectangle stops growing past the cursor; the canvas does not pan; off-screen objects cannot be reached.
  5. Expected: as the cursor enters the right margin (e.g., last 40px of canvas width), the canvas auto-pans rightward, the selection rectangle continues to grow, and off-screen objects are added to the selection as they enter the rectangle.

Root cause

crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js:

  • onMouseDown (line 491–496) sets isSelecting = true and snapshots selStart in world coordinates.
  • onMouseMove (line 593–599) updates the selection rect's x / y / w / h from 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' && isSelecting is true:

  1. On every onMouseMove while rubber-banding, capture the cursor's screen-space position relative to the stage container's bounding rect.
  2. Compute a per-axis pan velocity: zero when the cursor is more than EDGE_MARGIN pixels from any edge; otherwise scaled linearly from 0 at the margin boundary to MAX_PAN_SPEED (px/frame) at the very edge (or beyond — use Math.max(0, depth) so the speed clamps when the cursor moves past the canvas).
  3. While any pan velocity is non-zero, run a requestAnimationFrame loop 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).
  4. Stop the loop when (a) isSelecting flips to false (mouseup), (b) the cursor leaves all margins, or (c) the user switches tool / cancels.
  5. On mouseup, the existing finalization in 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.
  • (No changes to canvas.js, app.js, or any other module.)

Acceptance criteria

  • Starting a rubber-band selection and moving the cursor into the right margin pans the canvas rightward; off-screen objects to the right enter the selection.
  • Same for left, top, and bottom edges.
  • Pan speed scales with how deep the cursor sits inside the margin (faster near the edge).
  • Pan stops the moment the cursor leaves all margins, or the moment the mouse is released.
  • Releasing the mouse finalizes the selection exactly as today (no double-counting, no missed objects).
  • Auto-pan only fires while rubber-banding in select mode; never during normal cursor movement, never while dragging an object, never while in pan / draw / eraser / shape / sticky / etc. modes.
  • No regression in normal pan (H tool), zoom, or drag-to-move-object behavior.
  • Performance: the pan loop runs only while needed (cursor in margin during select drag) and yields each frame via requestAnimationFrame.

Out of scope

  • Auto-pan while dragging a selected object near the edge (separate UX, separate issue).
  • Configurable margin / speed in user settings.
  • Inertia / smooth deceleration after mouse leaves the margin.
## 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 1. Place objects spread across a wide area — some inside the current viewport, some off-screen to the right (or any direction). 2. Switch to Select mode (V). 3. Mousedown on empty canvas inside the viewport, drag toward the right edge until the cursor hits the edge of the canvas region. 4. Observed: the rubber-band rectangle stops growing past the cursor; the canvas does not pan; off-screen objects cannot be reached. 5. Expected: as the cursor enters the right margin (e.g., last 40px of canvas width), the canvas auto-pans rightward, the selection rectangle continues to grow, and off-screen objects are added to the selection as they enter the rectangle. ## Root cause `crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js`: - `onMouseDown` (line 491–496) sets `isSelecting = true` and snapshots `selStart` in world coordinates. - `onMouseMove` (line 593–599) updates the selection rect's `x` / `y` / `w` / `h` from 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' && isSelecting` is true: 1. On every `onMouseMove` while rubber-banding, capture the cursor's screen-space position relative to the stage container's bounding rect. 2. Compute a per-axis pan velocity: zero when the cursor is more than `EDGE_MARGIN` pixels from any edge; otherwise scaled linearly from 0 at the margin boundary to `MAX_PAN_SPEED` (px/frame) at the very edge (or beyond — use `Math.max(0, depth)` so the speed clamps when the cursor moves past the canvas). 3. While any pan velocity is non-zero, run a `requestAnimationFrame` loop 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). 4. Stop the loop when (a) `isSelecting` flips to false (mouseup), (b) the cursor leaves all margins, or (c) the user switches tool / cancels. 5. On mouseup, the existing finalization in `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. - (No changes to `canvas.js`, `app.js`, or any other module.) ## Acceptance criteria - [ ] Starting a rubber-band selection and moving the cursor into the right margin pans the canvas rightward; off-screen objects to the right enter the selection. - [ ] Same for left, top, and bottom edges. - [ ] Pan speed scales with how deep the cursor sits inside the margin (faster near the edge). - [ ] Pan stops the moment the cursor leaves all margins, or the moment the mouse is released. - [ ] Releasing the mouse finalizes the selection exactly as today (no double-counting, no missed objects). - [ ] Auto-pan only fires while rubber-banding in select mode; never during normal cursor movement, never while dragging an object, never while in pan / draw / eraser / shape / sticky / etc. modes. - [ ] No regression in normal pan (H tool), zoom, or drag-to-move-object behavior. - [ ] Performance: the pan loop runs only while needed (cursor in margin during select drag) and yields each frame via `requestAnimationFrame`. ## Out of scope - Auto-pan while dragging a selected object near the edge (separate UX, separate issue). - Configurable margin / speed in user settings. - Inertia / smooth deceleration after mouse leaves the margin.
Author
Member

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

  • Auto-pan only triggers while currentTool === 'select' && isSelecting === true.
  • Pan velocity per axis is proportional to how far the cursor has crossed into a configurable EDGE_MARGIN (screen pixels), capped at MAX_PAN_SPEED (px per frame at ~60fps).
  • A single requestAnimationFrame loop drives the pan; it must self-cancel when no axis has non-zero velocity, when isSelecting is cleared, when the tool changes, or on mouseup.
  • The selection rectangle's far corner must continue tracking the cursor in world coords after each pan tick — even when the mouse is held still inside a margin (rect grows as the world slides under the cursor).
  • The pan must use the same primitive existing pan paths use (stage.position(...) + WhiteboardCanvas.drawGrid()) so connector layer, grid, sticky overlays, and viewport listeners stay in sync.
  • No collab/sync messages emitted for the pan or the in-progress selection rectangle.
  • No auto-pan for: object drag, draw, eraser, connector, pan tool, middle-mouse pan, frame/sticky/text/shape creation tools.

Files to Modify/Create

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js — single-file change inside the WhiteboardTools IIFE.
  • canvas.js is consulted read-only; the existing pan pattern (stage.position({x,y}) then WhiteboardCanvas.drawGrid() — used by setupWheelZoom at canvas.js:236-242 and the middle-mouse handler at tools.js:386-391) is reused as-is. drawGrid() already triggers updateViewport() and notifies viewport listeners.

Implementation Plan

Step 1: Module-level constants and state

Files: tools.js

  • Next to the existing selectionRect / isSelecting / selStart declarations:
    • 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 DOM MouseEvent.clientX/Y so 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)

  • At the top of onMouseMove, when currentTool === 'select' && isSelecting, read e.evt.clientX/clientY and cache into lastClientX/Y. Konva exposes the underlying DOM event on e.evt.
  • Inside the existing select && isSelecting branch, after the selectionRect.setAttrs({...}) + batchDraw() lines, call maybeStartEdgePan().
    Dependencies: Step 1

Step 3: Velocity computation + rAF pan loop

Files: tools.js (new helpers near onMouseMove/onMouseUp)

  • computeEdgePanVelocity(stageBox) returns {vx, vy}:
    • vx is positive when lastClientX > stageBox.right - EDGE_MARGIN, negative when lastClientX < stageBox.left + EDGE_MARGIN. Magnitude (depth / EDGE_MARGIN) * MAX_PAN_SPEED, clamped to [0, MAX_PAN_SPEED]. Cursor outside the container clamps to MAX_PAN_SPEED. Sign matches the wheel-pan convention so stage.x() - vx slides off-screen-right content into view.
    • vy symmetric for top/bottom.
    • Both zero when cursor is in the comfortable interior.
  • maybeStartEdgePan():
    • Guard: if (currentTool !== 'select' || !isSelecting || panRafId != null) return;
    • Compute {vx, vy}; if both zero, do nothing.
    • Otherwise panRafId = requestAnimationFrame(edgePanTick);
  • edgePanTick():
    • Clear panRafId = null; first.
    • Defensive guard: if (currentTool !== 'select' || !isSelecting) return;
    • Compute fresh {vx, vy}. If both zero, stop (do not reschedule).
    • Apply pan: stage.position({ x: stage.x() - vx, y: stage.y() - vy }); WhiteboardCanvas.drawGrid();
    • Recompute world cursor: worldX = (lastClientX - stageBox.left - stage.x()) / stage.scaleX(); (and Y analogously). After-pan transform.
    • Update rect with the same shape math as the existing onMouseMove branch and batchDraw().
    • Reschedule panRafId = requestAnimationFrame(edgePanTick);
  • stopEdgePan(): cancel + null the handle.
    Dependencies: Steps 1-2

Step 4: Wire stop conditions

Files: tools.js

  • In onMouseUp inside the select && isSelecting block, call stopEdgePan() immediately before isSelecting = false;.
  • In setTool at the top, call stopEdgePan() unconditionally — covers tool change mid-drag.
  • The in-callback guard handles edge cases (window blur dropping the mouseup, etc.).
    Dependencies: Step 3

Acceptance Criteria

  • Rubber-banding into the right margin pans the canvas leftward (off-screen-right content slides into view) at a speed that increases with depth, capped at MAX_PAN_SPEED.
  • Same on left, top, bottom; corners pan diagonally.
  • While auto-panning, the selection rectangle's far corner stays glued to the cursor's screen position (the rect grows as new world area scrolls in).
  • Holding the mouse still inside a margin continues to pan; moving the cursor back into the comfortable interior stops the pan within one frame.
  • Mouseup ends the pan and finalizes the selection across the full grown rect, capturing originally-off-screen objects.
  • Switching tools mid-drag stops the pan loop and does not leak a rAF.
  • Auto-pan does NOT engage during object drag, H-tool pan, middle-mouse pan, draw, erase, connector, sticky/text/shape/frame creation.
  • Grid stays aligned, connectors track endpoints, webframe overlays reposition (all flow from drawGrid() -> updateViewport()).
  • No new collab/sync traffic during pan.

Notes

  • Pan primitive: canvas.js does not expose a panBy(dx, dy) helper. The two existing imperative-pan call sites both directly set stage.position({x, y}) and call WhiteboardCanvas.drawGrid(). Reuse that pattern verbatim — drawGrid() triggers updateViewport(), which fires every registered viewport listener. Do NOT introduce a new panBy export just for this feature.
  • Sign convention: 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, decrease stage.x() (apply stage.x() - vx with positive vx).
  • Screen-vs-world coords: Konva's stage.getPointerPosition() is event-driven, not poll-able. We must cache the DOM MouseEvent.clientX/Y on every onMouseMove and convert to world coords inside the rAF using the post-pan transform: worldX = (clientX - stageBox.left - stage.x()) / stage.scaleX().
  • rAF lifecycle: single module-level panRafId; null when not running. Set it just before scheduling and clear it as the first action in the callback. Cancel via stopEdgePan() in onMouseUp and setTool; the in-callback guard covers other edge cases. The if (panRafId != null) guard in maybeStartEdgePan enforces no chained rAFs.
  • The two existing requestAnimationFrame call sites in tools.js (eraser-related) are one-shot scheduling for unrelated work — no contention.
  • Multi-tab collab: pan is a local viewport change; the in-progress selectionRect is local UI. The finalized world-space selectionRect.getClientRect() in onMouseUp already captures the grown rect correctly (Konva computes it from the rect's own world attrs).
  • Velocity curve: linear ramp (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.
## 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 - Auto-pan only triggers while `currentTool === 'select' && isSelecting === true`. - Pan velocity per axis is proportional to how far the cursor has crossed into a configurable `EDGE_MARGIN` (screen pixels), capped at `MAX_PAN_SPEED` (px per frame at ~60fps). - A single `requestAnimationFrame` loop drives the pan; it must self-cancel when no axis has non-zero velocity, when `isSelecting` is cleared, when the tool changes, or on mouseup. - The selection rectangle's far corner must continue tracking the cursor in world coords after each pan tick — even when the mouse is held still inside a margin (rect grows as the world slides under the cursor). - The pan must use the same primitive existing pan paths use (`stage.position(...)` + `WhiteboardCanvas.drawGrid()`) so connector layer, grid, sticky overlays, and viewport listeners stay in sync. - No collab/sync messages emitted for the pan or the in-progress selection rectangle. - No auto-pan for: object drag, draw, eraser, connector, pan tool, middle-mouse pan, frame/sticky/text/shape creation tools. ### Files to Modify/Create - `crates/hero_whiteboard_ui/static/web/js/whiteboard/tools.js` — single-file change inside the `WhiteboardTools` IIFE. - `canvas.js` is consulted read-only; the existing pan pattern (`stage.position({x,y})` then `WhiteboardCanvas.drawGrid()` — used by `setupWheelZoom` at canvas.js:236-242 and the middle-mouse handler at tools.js:386-391) is reused as-is. `drawGrid()` already triggers `updateViewport()` and notifies viewport listeners. ### Implementation Plan #### Step 1: Module-level constants and state Files: `tools.js` - Next to the existing `selectionRect` / `isSelecting` / `selStart` declarations: - `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 DOM `MouseEvent.clientX/Y` so 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) - At the top of `onMouseMove`, when `currentTool === 'select' && isSelecting`, read `e.evt.clientX/clientY` and cache into `lastClientX/Y`. Konva exposes the underlying DOM event on `e.evt`. - Inside the existing `select && isSelecting` branch, after the `selectionRect.setAttrs({...})` + `batchDraw()` lines, call `maybeStartEdgePan()`. Dependencies: Step 1 #### Step 3: Velocity computation + rAF pan loop Files: `tools.js` (new helpers near `onMouseMove`/`onMouseUp`) - `computeEdgePanVelocity(stageBox)` returns `{vx, vy}`: - `vx` is positive when `lastClientX > stageBox.right - EDGE_MARGIN`, negative when `lastClientX < stageBox.left + EDGE_MARGIN`. Magnitude `(depth / EDGE_MARGIN) * MAX_PAN_SPEED`, clamped to `[0, MAX_PAN_SPEED]`. Cursor outside the container clamps to `MAX_PAN_SPEED`. Sign matches the wheel-pan convention so `stage.x() - vx` slides off-screen-right content into view. - `vy` symmetric for top/bottom. - Both zero when cursor is in the comfortable interior. - `maybeStartEdgePan()`: - Guard: `if (currentTool !== 'select' || !isSelecting || panRafId != null) return;` - Compute `{vx, vy}`; if both zero, do nothing. - Otherwise `panRafId = requestAnimationFrame(edgePanTick);` - `edgePanTick()`: - Clear `panRafId = null;` first. - Defensive guard: `if (currentTool !== 'select' || !isSelecting) return;` - Compute fresh `{vx, vy}`. If both zero, stop (do not reschedule). - Apply pan: `stage.position({ x: stage.x() - vx, y: stage.y() - vy }); WhiteboardCanvas.drawGrid();` - Recompute world cursor: `worldX = (lastClientX - stageBox.left - stage.x()) / stage.scaleX();` (and Y analogously). After-pan transform. - Update rect with the same shape math as the existing `onMouseMove` branch and `batchDraw()`. - Reschedule `panRafId = requestAnimationFrame(edgePanTick);` - `stopEdgePan()`: cancel + null the handle. Dependencies: Steps 1-2 #### Step 4: Wire stop conditions Files: `tools.js` - In `onMouseUp` inside the `select && isSelecting` block, call `stopEdgePan()` immediately before `isSelecting = false;`. - In `setTool` at the top, call `stopEdgePan()` unconditionally — covers tool change mid-drag. - The in-callback guard handles edge cases (window blur dropping the mouseup, etc.). Dependencies: Step 3 ### Acceptance Criteria - [ ] Rubber-banding into the right margin pans the canvas leftward (off-screen-right content slides into view) at a speed that increases with depth, capped at `MAX_PAN_SPEED`. - [ ] Same on left, top, bottom; corners pan diagonally. - [ ] While auto-panning, the selection rectangle's far corner stays glued to the cursor's screen position (the rect grows as new world area scrolls in). - [ ] Holding the mouse still inside a margin continues to pan; moving the cursor back into the comfortable interior stops the pan within one frame. - [ ] Mouseup ends the pan and finalizes the selection across the full grown rect, capturing originally-off-screen objects. - [ ] Switching tools mid-drag stops the pan loop and does not leak a rAF. - [ ] Auto-pan does NOT engage during object drag, H-tool pan, middle-mouse pan, draw, erase, connector, sticky/text/shape/frame creation. - [ ] Grid stays aligned, connectors track endpoints, webframe overlays reposition (all flow from `drawGrid()` -> `updateViewport()`). - [ ] No new collab/sync traffic during pan. ### Notes - Pan primitive: `canvas.js` does not expose a `panBy(dx, dy)` helper. The two existing imperative-pan call sites both directly set `stage.position({x, y})` and call `WhiteboardCanvas.drawGrid()`. Reuse that pattern verbatim — `drawGrid()` triggers `updateViewport()`, which fires every registered viewport listener. Do NOT introduce a new `panBy` export just for this feature. - Sign convention: `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, decrease `stage.x()` (apply `stage.x() - vx` with positive `vx`). - Screen-vs-world coords: Konva's `stage.getPointerPosition()` is event-driven, not poll-able. We must cache the DOM `MouseEvent.clientX/Y` on every `onMouseMove` and convert to world coords inside the rAF using the post-pan transform: `worldX = (clientX - stageBox.left - stage.x()) / stage.scaleX()`. - rAF lifecycle: single module-level `panRafId`; null when not running. Set it just before scheduling and clear it as the first action in the callback. Cancel via `stopEdgePan()` in `onMouseUp` and `setTool`; the in-callback guard covers other edge cases. The `if (panRafId != null)` guard in `maybeStartEdgePan` enforces no chained rAFs. - The two existing `requestAnimationFrame` call sites in `tools.js` (eraser-related) are one-shot scheduling for unrelated work — no contention. - Multi-tab collab: pan is a local viewport change; the in-progress `selectionRect` is local UI. The finalized world-space `selectionRect.getClientRect()` in `onMouseUp` already captures the grown rect correctly (Konva computes it from the rect's own world attrs). - Velocity curve: linear ramp (`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.
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/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.

## 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/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.
Author
Member

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:
    • Constants: EDGE_MARGIN = 40 (screen px), MAX_PAN_SPEED = 18 (px/frame at 60fps).
    • State: panRafId (single rAF handle), lastClientX/lastClientY (cached DOM MouseEvent coords 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 when currentTool === 'select' && isSelecting && panRafId == null.
    • edgePanTick() — pans the stage via stage.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 from e.evt and calls maybeStartEdgePan().
    • onMouseUp (select branch): calls stopEdgePan() before clearing isSelecting.
    • setTool: calls stopEdgePan() 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-callback if (currentTool !== 'select' || !isSelecting) return; guard covers any edge case (window blur dropping the mouseup, etc.).

Test Results

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

Manual Verification Required

  1. Place objects across a wide area, some inside the viewport and some off-screen in each direction.
  2. Switch to Select mode (V). Mousedown on empty canvas, drag toward the right edge — canvas pans rightward at increasing speed as the cursor goes deeper into the margin; the rect grows to track the cursor.
  3. Same for left, top, bottom; corners pan diagonally.
  4. Hold the mouse still inside a margin — pan continues. Move back into the comfortable interior — pan stops within one frame.
  5. Release the mouse — selection finalizes across the full grown rect, including originally-off-screen objects.
  6. Press another tool hotkey mid-drag — pan stops cleanly with no leaked rAF.
  7. Auto-pan does NOT engage during object drag, H-tool pan, middle-mouse pan, draw, eraser, connector, or any creation tool (sticky/text/shape/frame/etc.).
  8. Grid stays aligned during the pan; connectors / webframe overlays follow correctly.
## 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: - Constants: `EDGE_MARGIN = 40` (screen px), `MAX_PAN_SPEED = 18` (px/frame at 60fps). - State: `panRafId` (single rAF handle), `lastClientX`/`lastClientY` (cached DOM `MouseEvent` coords 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 when `currentTool === 'select' && isSelecting && panRafId == null`. - `edgePanTick()` — pans the stage via `stage.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 from `e.evt` and calls `maybeStartEdgePan()`. - `onMouseUp` (select branch): calls `stopEdgePan()` before clearing `isSelecting`. - `setTool`: calls `stopEdgePan()` 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-callback `if (currentTool !== 'select' || !isSelecting) return;` guard covers any edge case (window blur dropping the mouseup, etc.). ### Test Results - `cargo test --workspace --lib` — PASS - `cargo fmt --check` — PASS - `cargo clippy --workspace --all-targets -- -D warnings` — PASS ### Manual Verification Required 1. Place objects across a wide area, some inside the viewport and some off-screen in each direction. 2. Switch to Select mode (V). Mousedown on empty canvas, drag toward the right edge — canvas pans rightward at increasing speed as the cursor goes deeper into the margin; the rect grows to track the cursor. 3. Same for left, top, bottom; corners pan diagonally. 4. Hold the mouse still inside a margin — pan continues. Move back into the comfortable interior — pan stops within one frame. 5. Release the mouse — selection finalizes across the full grown rect, including originally-off-screen objects. 6. Press another tool hotkey mid-drag — pan stops cleanly with no leaked rAF. 7. Auto-pan does NOT engage during object drag, H-tool pan, middle-mouse pan, draw, eraser, connector, or any creation tool (sticky/text/shape/frame/etc.). 8. Grid stays aligned during the pan; connectors / webframe overlays follow correctly.
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#146
No description provided.