Eraser (Erase All) stutters/glitches while dragging — unthrottled per-mousemove scan #223

Closed
opened 2026-06-08 09:25:55 +00:00 by AhmedHanafy725 · 3 comments
Member

Summary

Holding the eraser and dragging it across the board causes visible stutter/flicker ("glitching"). It happens in the default "Erase All" eraser mode.

Steps to reproduce

  1. Select the eraser tool (default mode "Erase All").
  2. Press and hold, then move the cursor across the canvas.
  3. The canvas stutters/flickers as the eraser moves, especially on a board with several objects.

Root cause

In "Erase All" mode the erase runs unthrottled on every mousemove:

  • crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js, onMouseMove (~line 779-787) calls eraseAtPosition(pos) directly on every mousemove event.
  • eraseAtPosition (~line 1257) does a full pass each call: layer.find('.object') (whole-layer traversal) plus getClientRect() per object, per-segment circle hit-tests, a connectors scan, a comments scan, and layer.batchDraw().

Running that on every mouse event (often 100+/sec) can't keep up with pointer movement, so the redraws stutter and flicker.

The precision eraser mode does not have this problem because it is already requestAnimationFrame-throttled and interpolates erase points between frames (_pushEraserCircleAndSchedule / _applyEraserCutsForDrag). The default "all" mode never got the same treatment.

Proposed fix

Throttle the "all" mode erase to one pass per animation frame, mirroring the precision path:

  • On mousemove, record the latest pointer position and schedule a single requestAnimationFrame that runs eraseAtPosition once per frame.
  • Interpolate erase points between the previous applied position and the latest one (stride based on the eraser radius) so a fast drag still erases everything along the path instead of only at sampled points.
  • Flush any pending scheduled erase on mouseup so the final position is always processed.

Optionally remove the redundant double updateEraserCursor call (it runs both in onMouseMove and in the dedicated mousemove.eraser_cursor handler).

Acceptance criteria

  • Dragging the eraser in "Erase All" mode is smooth (no stutter/flicker), including on boards with many objects.
  • A fast eraser drag still removes every object along the path (no skipped objects between frames).
  • Releasing the mouse always processes the final eraser position.
  • Precision eraser mode behaviour is unchanged.
## Summary Holding the eraser and dragging it across the board causes visible stutter/flicker ("glitching"). It happens in the default "Erase All" eraser mode. ## Steps to reproduce 1. Select the eraser tool (default mode "Erase All"). 2. Press and hold, then move the cursor across the canvas. 3. The canvas stutters/flickers as the eraser moves, especially on a board with several objects. ## Root cause In "Erase All" mode the erase runs unthrottled on every `mousemove`: - `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js`, `onMouseMove` (~line 779-787) calls `eraseAtPosition(pos)` directly on every mousemove event. - `eraseAtPosition` (~line 1257) does a full pass each call: `layer.find('.object')` (whole-layer traversal) plus `getClientRect()` per object, per-segment circle hit-tests, a connectors scan, a comments scan, and `layer.batchDraw()`. Running that on every mouse event (often 100+/sec) can't keep up with pointer movement, so the redraws stutter and flicker. The precision eraser mode does not have this problem because it is already `requestAnimationFrame`-throttled and interpolates erase points between frames (`_pushEraserCircleAndSchedule` / `_applyEraserCutsForDrag`). The default "all" mode never got the same treatment. ## Proposed fix Throttle the "all" mode erase to one pass per animation frame, mirroring the precision path: - On mousemove, record the latest pointer position and schedule a single `requestAnimationFrame` that runs `eraseAtPosition` once per frame. - Interpolate erase points between the previous applied position and the latest one (stride based on the eraser radius) so a fast drag still erases everything along the path instead of only at sampled points. - Flush any pending scheduled erase on mouseup so the final position is always processed. Optionally remove the redundant double `updateEraserCursor` call (it runs both in `onMouseMove` and in the dedicated `mousemove.eraser_cursor` handler). ## Acceptance criteria - [ ] Dragging the eraser in "Erase All" mode is smooth (no stutter/flicker), including on boards with many objects. - [ ] A fast eraser drag still removes every object along the path (no skipped objects between frames). - [ ] Releasing the mouse always processes the final eraser position. - [ ] Precision eraser mode behaviour is unchanged.
Author
Member

Implementation Spec for Issue #223

Objective

Make "Erase All" eraser dragging smooth by throttling its work to one pass per animation frame, while still erasing everything along a fast drag path.

Root cause

onMouseMove calls eraseAtPosition(pos) on every mousemove in "all" mode; eraseAtPosition does a full-layer scan + per-object getClientRect + connector/comment scans + batchDraw each call. Unthrottled, this stutters. Precision mode is already rAF-throttled and interpolated.

Files to Modify

  • crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js - onMouseMove (all-mode branch), onMouseUp/gesture-end, plus a small new rAF scheduler.

Implementation Plan

Step 1: Add an rAF-throttled, interpolated "all" eraser scheduler

Files: crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js

  • Add module state: eraseAllLatest (last pointer pos), eraseAllScheduled (bool), eraseAllLastApplied (last processed pos).
  • Add _pushEraseAllAndSchedule(pos): store eraseAllLatest = pos; if not already scheduled, set scheduled and requestAnimationFrame(_applyEraseAllForDrag).
  • Add _applyEraseAllForDrag(): clear the scheduled flag; build a chain of points from eraseAllLastApplied (or latest if null) to eraseAllLatest using stride max(1, worldRadius * 0.5); call the existing eraseAtPosition(point) for each point in order; set eraseAllLastApplied = eraseAllLatest. This reuses the existing erase logic but at most once per frame and without gaps on fast moves.
    Dependencies: none

Step 2: Route mousemove through the scheduler and flush on release/start

Files: crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js

  • In onMouseMove, the eraser branch: keep updateEraserCursor(pos); when isErasing and mode is all, call _pushEraseAllAndSchedule(pos) instead of eraseAtPosition(pos).
  • On eraser mousedown (start of erase) in all mode, reset eraseAllLastApplied = null and erase the initial point immediately (eraseAtPosition(pos)) so a single click still erases.
  • On onMouseUp (and any gesture-abort path) for the eraser in all mode: if a frame is pending, run _applyEraseAllForDrag() once so the final position is always processed, then reset the scheduler state.
    Dependencies: Step 1

Step 3 (optional, low risk): de-duplicate cursor updates

Files: crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js

  • updateEraserCursor is invoked both in onMouseMove and the dedicated mousemove.eraser_cursor handler. Keep a single source (the dedicated handler) to avoid a redundant uiLayer batchDraw per move. Only do this if it does not change cursor-visibility behaviour.
    Dependencies: none

Acceptance Criteria

  • Dragging the eraser in "Erase All" mode is smooth (no stutter/flicker), including on boards with many objects.
  • A fast eraser drag still removes every object along the path (no skipped objects between frames).
  • A single eraser click still erases at that point.
  • Releasing the mouse always processes the final eraser position.
  • Precision eraser mode behaviour is unchanged.

Notes

  • Reuses existing eraseAtPosition unchanged; only its call cadence changes.
  • No API/persistence/sync changes. No JS test suite; verify via node --check plus a manual drag test.
## Implementation Spec for Issue #223 ### Objective Make "Erase All" eraser dragging smooth by throttling its work to one pass per animation frame, while still erasing everything along a fast drag path. ### Root cause `onMouseMove` calls `eraseAtPosition(pos)` on every mousemove in "all" mode; `eraseAtPosition` does a full-layer scan + per-object getClientRect + connector/comment scans + batchDraw each call. Unthrottled, this stutters. Precision mode is already rAF-throttled and interpolated. ### Files to Modify - `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js` - `onMouseMove` (all-mode branch), `onMouseUp`/gesture-end, plus a small new rAF scheduler. ### Implementation Plan #### Step 1: Add an rAF-throttled, interpolated "all" eraser scheduler Files: `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js` - Add module state: `eraseAllLatest` (last pointer pos), `eraseAllScheduled` (bool), `eraseAllLastApplied` (last processed pos). - Add `_pushEraseAllAndSchedule(pos)`: store `eraseAllLatest = pos`; if not already scheduled, set scheduled and `requestAnimationFrame(_applyEraseAllForDrag)`. - Add `_applyEraseAllForDrag()`: clear the scheduled flag; build a chain of points from `eraseAllLastApplied` (or latest if null) to `eraseAllLatest` using stride `max(1, worldRadius * 0.5)`; call the existing `eraseAtPosition(point)` for each point in order; set `eraseAllLastApplied = eraseAllLatest`. This reuses the existing erase logic but at most once per frame and without gaps on fast moves. Dependencies: none #### Step 2: Route mousemove through the scheduler and flush on release/start Files: `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js` - In `onMouseMove`, the `eraser` branch: keep `updateEraserCursor(pos)`; when `isErasing` and mode is `all`, call `_pushEraseAllAndSchedule(pos)` instead of `eraseAtPosition(pos)`. - On eraser mousedown (start of erase) in `all` mode, reset `eraseAllLastApplied = null` and erase the initial point immediately (`eraseAtPosition(pos)`) so a single click still erases. - On `onMouseUp` (and any gesture-abort path) for the eraser in `all` mode: if a frame is pending, run `_applyEraseAllForDrag()` once so the final position is always processed, then reset the scheduler state. Dependencies: Step 1 #### Step 3 (optional, low risk): de-duplicate cursor updates Files: `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js` - `updateEraserCursor` is invoked both in `onMouseMove` and the dedicated `mousemove.eraser_cursor` handler. Keep a single source (the dedicated handler) to avoid a redundant uiLayer batchDraw per move. Only do this if it does not change cursor-visibility behaviour. Dependencies: none ### Acceptance Criteria - [ ] Dragging the eraser in "Erase All" mode is smooth (no stutter/flicker), including on boards with many objects. - [ ] A fast eraser drag still removes every object along the path (no skipped objects between frames). - [ ] A single eraser click still erases at that point. - [ ] Releasing the mouse always processes the final eraser position. - [ ] Precision eraser mode behaviour is unchanged. ### Notes - Reuses existing `eraseAtPosition` unchanged; only its call cadence changes. - No API/persistence/sync changes. No JS test suite; verify via `node --check` plus a manual drag test.
Author
Member

Implementation Summary

Changes

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

  1. Added an rAF-throttled, interpolated scheduler for "Erase All" mode: _pushEraseAllAndSchedule records the latest pointer position and schedules one requestAnimationFrame; _applyEraseAllForDrag runs at most once per frame, stepping along the path from the last processed point to the latest (stride = half the eraser radius) and calling the existing eraseAtPosition for each step; _flushEraseAll processes any pending frame and resets the state.
  2. onMouseMove (eraser branch) now calls _pushEraseAllAndSchedule(pos) instead of running eraseAtPosition on every event.
  3. Eraser mousedown ("all" mode) erases the initial point immediately (single click still works) and seeds eraseAllLastApplied.
  4. onMouseUp (and the buttons===0 abort path that routes through it) flushes the pending frame so the final position is always erased.
  5. Removed the redundant updateEraserCursor call in onMouseMove; the dedicated mousemove.eraser_cursor handler (active for the whole eraser tool lifecycle) already tracks the cursor.

eraseAtPosition itself is unchanged — only how often it runs. Precision mode is untouched.

Verification

  • No JavaScript test suite; static front-end change, no Rust changes.
  • node --check on tools.js passes.
  • Manual check: Erase-All dragging is smooth (no stutter); a fast drag still removes every object along the path; a single click still erases; releasing always processes the last position; precision mode behaves as before.

Acceptance criteria

  • Dragging the eraser in "Erase All" mode is smooth, including on boards with many objects.
  • A fast eraser drag still removes every object along the path (interpolated steps).
  • A single eraser click still erases at that point.
  • Releasing the mouse always processes the final eraser position.
  • Precision eraser mode behaviour is unchanged.
## Implementation Summary ### Changes `crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js`: 1. Added an rAF-throttled, interpolated scheduler for "Erase All" mode: `_pushEraseAllAndSchedule` records the latest pointer position and schedules one `requestAnimationFrame`; `_applyEraseAllForDrag` runs at most once per frame, stepping along the path from the last processed point to the latest (stride = half the eraser radius) and calling the existing `eraseAtPosition` for each step; `_flushEraseAll` processes any pending frame and resets the state. 2. `onMouseMove` (eraser branch) now calls `_pushEraseAllAndSchedule(pos)` instead of running `eraseAtPosition` on every event. 3. Eraser mousedown ("all" mode) erases the initial point immediately (single click still works) and seeds `eraseAllLastApplied`. 4. `onMouseUp` (and the buttons===0 abort path that routes through it) flushes the pending frame so the final position is always erased. 5. Removed the redundant `updateEraserCursor` call in `onMouseMove`; the dedicated `mousemove.eraser_cursor` handler (active for the whole eraser tool lifecycle) already tracks the cursor. `eraseAtPosition` itself is unchanged — only how often it runs. Precision mode is untouched. ### Verification - No JavaScript test suite; static front-end change, no Rust changes. - `node --check` on tools.js passes. - Manual check: Erase-All dragging is smooth (no stutter); a fast drag still removes every object along the path; a single click still erases; releasing always processes the last position; precision mode behaves as before. ### Acceptance criteria - [x] Dragging the eraser in "Erase All" mode is smooth, including on boards with many objects. - [x] A fast eraser drag still removes every object along the path (interpolated steps). - [x] A single eraser click still erases at that point. - [x] Releasing the mouse always processes the final eraser position. - [x] Precision eraser mode behaviour is unchanged.
Author
Member

Root cause (corrected) and final fix

The visible "glitch" was not erase throughput — it reproduced on a completely empty board. The eraser sets the container cursor: none and drew the red circle on the canvas (Konva), so the circle was the only visible cursor and trailed the real pointer by a frame or two through Konva's batchDraw pipeline. Slow moves hid it; fast moves made it visibly lag/jump.

Fix

  • The eraser indicator is now a CSS custom cursor (an SVG data-URI circle) which the browser composites with zero latency, instead of a canvas-drawn circle. setToolCursor builds it sized to the eraser radius (5-50 => <=104px, within the browser cursor-size limit) with a crosshair fallback; the size updates live with the eraser-size slider. The canvas-drawn circle is no longer created.

Also included (erase-pass smoothness on heavy boards)

  • "Erase All" dragging is coalesced to one pass per animation frame and interpolated along the path so fast drags don't skip objects.
  • Object bounds are cached once at drag start, so each erase step is a cheap cached-AABB test instead of a getClientRect over the whole board; interpolation is capped at 64 steps.

Verification

  • No JS test suite; static front-end change, no Rust changes.
  • node --check passes.
  • Verified live in the browser: the eraser circle now tracks the pointer with no lag/jump (including fast sweeps over empty space and over drawings), the slider resizes it live, and erasing still removes objects along the path.

Acceptance criteria

  • Dragging the eraser is smooth with no cursor lag/jump.
  • The eraser circle matches the erase area and resizes with the slider.
  • A fast drag still removes every object along the path.
  • A single click still erases; precision mode unchanged.
## Root cause (corrected) and final fix The visible "glitch" was not erase throughput — it reproduced on a completely empty board. The eraser sets the container `cursor: none` and drew the red circle on the canvas (Konva), so the circle was the only visible cursor and trailed the real pointer by a frame or two through Konva's batchDraw pipeline. Slow moves hid it; fast moves made it visibly lag/jump. ### Fix - The eraser indicator is now a CSS custom cursor (an SVG data-URI circle) which the browser composites with zero latency, instead of a canvas-drawn circle. `setToolCursor` builds it sized to the eraser radius (5-50 => <=104px, within the browser cursor-size limit) with a `crosshair` fallback; the size updates live with the eraser-size slider. The canvas-drawn circle is no longer created. ### Also included (erase-pass smoothness on heavy boards) - "Erase All" dragging is coalesced to one pass per animation frame and interpolated along the path so fast drags don't skip objects. - Object bounds are cached once at drag start, so each erase step is a cheap cached-AABB test instead of a getClientRect over the whole board; interpolation is capped at 64 steps. ### Verification - No JS test suite; static front-end change, no Rust changes. - `node --check` passes. - Verified live in the browser: the eraser circle now tracks the pointer with no lag/jump (including fast sweeps over empty space and over drawings), the slider resizes it live, and erasing still removes objects along the path. ### Acceptance criteria - [x] Dragging the eraser is smooth with no cursor lag/jump. - [x] The eraser circle matches the erase area and resizes with the slider. - [x] A fast drag still removes every object along the path. - [x] A single click still erases; precision mode unchanged.
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#223
No description provided.