performance enhancements and some bug fixes #225

Merged
AhmedHanafy725 merged 10 commits from integration into main 2026-06-08 11:40:07 +00:00
Member
No description provided.
Dragging and mindmaps were slow because work scaled with board/tree size and
ran unthrottled on every frame. No single commit regressed it — the cost was
structural and grew with content.

tools.js (drag/move hot path):
- Snap guides: build the candidate-bounds list once at dragstart (was an
  O(n) findOne('.bg') tree search over all objects every frame), coalesce the
  dragmove burst into one render per animation frame, and reuse a pool of
  hidden/shown guide lines instead of destroying + recreating Konva.Line nodes
  each frame. Nodes moving with the drag are excluded from snap targets.
- Disable the object layer's hit-graph redraw during a drag
  (listening(false) on dragstart, restored on dragend); drag/snap/connector
  events are dispatched by the drag system, not the hit graph, so they keep
  firing while the per-frame draw cost roughly halves.

mindmap.js:
- Memoize node text and description-height measurements. renderMindmap rebuilds
  the whole tree on every interaction (collapse, add, edit, remote sync); it was
  recreating a Konva.Text probe and rendering each node's markdown twice per
  render. Measurements are deterministic in (text, font), so cached entries need
  no invalidation.
- Cache the mindmap group to a bitmap for the duration of its own drag, so each
  frame blits one image instead of redrawing every node + drop shadow; cleared
  on dragend to restore vector rendering.
On a crowded board the previous changes still re-rastered the whole object
layer every drag frame, because Konva redraws the layer that the dragged node
lives on. Add a dedicated drag layer (canvas.js) and, for the duration of a
drag, move the dragged node(s) — and the transformer when the selection itself
is dragged — onto it, restoring them to their original parent and stacking on
drag end. Now only the few dragged nodes redraw per frame; the bulk of the
board sits static on the object layer.

- tools.js: _enterDragLayer/_exitDragLayer migrate + restore (parent + zIndex);
  drag target resolves to the whole multi-selection when it's part of it.
- Lifecycle handlers bound to both the object and drag layers, since after
  migration dragend/dragmove bubble through the drag layer, not the object
  layer. onObjectDragEnd is idempotent.
- Interrupted drags (window blur, tab switch, ESC) now unwind the migration and
  restore object-layer interactivity via a shared abortActiveDrag(), fixing a
  case where the layer could be left non-interactive.
Profiling the reported case showed the whole board feels heavy regardless of
gesture (pan, zoom, hover, select) — because Konva redraws every object on the
layer each frame, including the many that are off-screen. The dedicated drag
layer only helped object dragging; pan/zoom/hover still paid for the full board.

Add viewport culling: hide object groups fully outside the visible area (plus a
half-screen margin) so each redraw costs O(on-screen items), not O(all items).
Off-screen items stay hidden until the viewport moves again, so subsequent
hover/drag/select redraws are cheap too.

- objects.js: cullToViewport() toggles only the group's own visibility (children
  keep their state, and getClientRect still reports real bounds, so the minimap
  is unaffected). Each object's world box is cached on the group and recomputed
  only when its transform changes, so panning re-projects cached boxes instead of
  walking every subtree each frame. Skipped under 30 objects and when the stage
  isn't sized yet.
- app.js: run culling on viewport change (rAF-throttled) and once after load.
- kanban.js / mindmap.js: invalidate the cached cull box on re-render, since a
  rebuild can change an object's size without moving it.
The exported board (ll-80.json) is only 36 objects / ~1.4k drawing points, yet
feels heavy everywhere — so the cost is per-shape, not count. Konva's
_useBufferCanvas double-buffers any shape with fill+stroke+alpha (or a shadow)
into an offscreen canvas on *every* draw (perfectDraw). Mindmap nodes, kanban
cells, shapes and document chrome all qualify, so a single full-layer redraw
(pan/zoom/hover) pays for dozens of offscreen passes.

Disable perfect-draw (and stroke shadows) on object-layer shapes — visually
near-identical, but it removes the offscreen pass so every redraw is cheaper.

- objects.js: optimizeLayerShapes() sweeps the object layer setting
  perfectDrawEnabled(false)+shadowForStrokeEnabled(false); scheduleOptimize()
  is an rAF-debounced wrapper. Freehand drawings also set the flags at creation
  (they have no fill/shadow, so the buffer was pure overhead).
- app.js: sweep once after load.
- mindmap.js / kanban.js: re-sweep after each rebuild so freshly built node and
  card shapes stay cheap.
Brave's fingerprint protection adds noise to the Canvas getImageData API, which
breaks Konva's hit detection — objects can't be dragged, the native context menu
appears instead of the whiteboard's, and behaviour is randomly inconsistent
(documented in the README "Brave Browser" section).

Add a dismissible banner that detects Brave via navigator.brave.isBrave() and
shows the fix steps (turn off Shields / allow all fingerprinting, disable HW
acceleration, avoid Private/Tor windows). Dismissal is remembered per browser in
localStorage. Loaded on both the editable board and the read-only board view.
navigator.brave.isBrave() is only exposed in a secure context (HTTPS,
localhost, 127.0.0.1, [::1]), so over plain HTTP on a routable IP (e.g. a
mycelium address) the signal disappears and the warning never showed.

Add a context-independent fallback that detects the actual root cause:
fingerprint protection perturbing the Canvas getImageData API. Opaque solid
fills read back deterministically in a normal browser, so any deviation means
the canvas output is being farbled — exactly what breaks Konva hit detection.
The Brave API is still used when available (exact "Brave detected" wording);
the heuristic falls back to a more general "fingerprint protection" message.
The inline mindmap title editor used a transparent-background input and never
hid the underlying Konva title node, so the existing title showed through and
made the typed text unreadable. Hide the title node while the editor is open
(the commit path rebuilds it via renderMindmap; the locked early-return path
restores it explicitly).

#221
The drawing's invisible full-bbox rect was hit-listening, and a transparent
Konva fill still registers pointer hits across its whole area — so a mousedown
on empty space inside the bounding box grabbed the drawing and swallowed the
rubber-band selection. Make that rect listening:false (it's only for sizing;
findOne('.bg')/getClientRect/snapping/culling are unaffected) and give the ink
lines a hitStrokeWidth of max(strokeWidth, 24) so the drawing is grabbed only
on or near the strokes, while far-empty areas start a selection.

#222
The eraser hid the OS pointer (cursor: none) and drew the red circle on the
canvas, so the circle trailed the real pointer through Konva's batchDraw
pipeline and visibly lagged/jumped on fast moves — it reproduced even on an
empty board, so it was the cursor's render path, not erase work. Replace the
canvas-drawn circle with a CSS custom cursor (SVG data-URI), which the browser
composites with zero latency; it's sized to the eraser radius (<=104px, within
the browser cursor limit), resizes live with the slider, and falls back to
crosshair.

Also smooths the Erase-All pass on heavy boards: coalesce it to one rAF per
frame, interpolate along the path so fast drags don't skip objects, and cache
each object's bounds at drag start so a step is a cheap cached-AABB test instead
of a getClientRect over the whole board (capped at 64 steps).

#223
fix(whiteboard): rubber-band selects a drawing only when it crosses the ink
All checks were successful
Build and Test / build (pull_request) Successful in 5m16s
4b3845b0c0
The rubber-band selection matched any object whose bounding box intersected the
selection rectangle. A freehand drawing's bbox includes the blank space between
strokes, so a rectangle dragged over an empty pocket inside it still selected
the drawing. Keep the cheap bbox pre-filter, but for drawings require the
selection rectangle (in world coords) to actually cross an ink segment, via a
Liang-Barsky segment-vs-box test that mirrors the world-coord iteration already
used by _drawingHitByCircle. Other object types are unchanged.

#224
Sign in to join this conversation.
No reviewers
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!225
No description provided.