Skip to content

Whiteboard: laser dot/trail fix, object snapping, letter hotkeys, per-user close#200

Open
pj4real wants to merge 3 commits into
ACM-VIT:stagingfrom
pj4real:staging
Open

Whiteboard: laser dot/trail fix, object snapping, letter hotkeys, per-user close#200
pj4real wants to merge 3 commits into
ACM-VIT:stagingfrom
pj4real:staging

Conversation

@pj4real

@pj4real pj4real commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

  • Laser pointer (closes Show whiteboard laser only while click-and-hold is active #190): selecting the laser now shows a plain dot that follows the cursor; a fading trail only paints while the button is held, and releasing lets it fade out on its own instead of vanishing instantly. The trail is also drawn as a handful of smoothed, independently-faded bands instead of one straight segment per point pair, so it reads as a continuous line rather than a string of dots.
  • Object snapping (closes Improve whiteboard shape selection and arrow snapping #191): dragging or resizing a shape now snaps to nearby elements' edges/centers (Figma-style alignment guides), with a guide line shown while the gesture is active.
  • Tool hotkeys: remapped to letter mnemonics (V select, H pan, P pen, E eraser, R rect, O ellipse, L line, A arrow, T text, S sticky, K laser), with 1-9 kept as a secondary alias in the original order. Every toolbar button now shows its shortcut as a small persistent badge instead of only a hover tooltip.
  • Per-user close: the whiteboard now has a corner close control. Everyone can hide it just for themselves (with a "Show whiteboard" control to bring it back while it's still running), and admins additionally get a "close for everyone" option. Also fixed the Apps panel, which previously disabled all interaction for non-admins and — for an admin — would have called closeApp() (closing for everyone) instead of un-hiding it locally.
  • Cursor labels: remote presence cursors now show first name only, keeping the label compact next to the pointer.

Test plan

  • pnpm typecheck passes for apps-sdk and apps/web
  • Manually verified in two live browser sessions (admin + participant) against a local dev server: drawing, moving, and resizing a shape all sync correctly in both directions
  • Verified laser dot/trail behavior and toolbar hotkey badges visually
  • Verified close-for-me / close-for-everyone flow, including the Apps panel rejoin fix

spent a lot of water and tokens to review your slop

Greptile Summary

This PR expands the collaborative whiteboard experience. The main changes are:

  • Adds edge and center snapping with alignment guides.
  • Reworks laser pointers into live dots and fading trails.
  • Adds mnemonic tool shortcuts and persistent badges.
  • Adds per-user hiding, rejoin controls, and admin shared close.
  • Shortens remote cursor labels to first names.

Confidence Score: 4/5

Incremental object dragging can remain stuck on a snap target and should be fixed before merging.

Snapping starts each move from the element's already-snapped position. Small pointer movements can repeatedly snap back to the same target. Pointer cancellation can leave guides and gesture state active. No security issue was identified in the changed close controls.

packages/apps-sdk/src/apps/whiteboard/core/tools/engine.ts and packages/apps-sdk/src/apps/whiteboard/web/components/WhiteboardCanvas.tsx

T-Rex T-Rex Logs

What T-Rex did

  • The TypeScript harness against the real ToolEngine and Yjs helpers reproduced the snapped drag that remained pinned at x=80 with snap guides, despite four consecutive -3 unit pointer moves.
  • With a target at x=100 and a 5-unit threshold, the test observed that cumulative movement beyond the threshold did not detach the element, which remained snapped at x=80.
  • A runtime trace was collected to show raw pointer deltas, cumulative movement, bounds, and snap guides, and it confirmed that the final positions remained [80,80,80,80].
  • UI-level evidence was captured as before-server and after-server logs to verify the UI flow, and no screenshots or videos were produced due to blank, loading, or timeout captures.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
packages/apps-sdk/src/apps/whiteboard/core/tools/engine.ts Adds move snapping and guide state, but incremental movement can prevent an object from detaching gradually.
packages/apps-sdk/src/apps/whiteboard/core/tools/snapping.ts Adds per-axis edge and center matching with guide generation.
packages/apps-sdk/src/apps/whiteboard/web/components/WhiteboardCanvas.tsx Integrates snapping, guide rendering, and revised laser behavior, but cancellation lacks gesture cleanup.
packages/apps-sdk/src/apps/whiteboard/web/components/WhiteboardWebApp.tsx Adds mnemonic shortcuts, local and shared close controls, and shorter cursor labels.
packages/apps-sdk/src/sdk/runtime/AppsProvider.tsx Adds local per-tab app visibility state that resets when the active shared app changes.
apps/web/src/app/components/apps/AppsPanel.tsx Allows participants to rejoin a locally hidden active app while retaining admin-only shared management.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Pointer[Pointer move] --> Canvas[Whiteboard canvas]
  Canvas --> Engine[Tool engine]
  Engine --> Bounds[Prospective bounds]
  Bounds --> Snap[Snap adjustment]
  Snap --> Document[Yjs element update]
  Snap --> Guides[Guide overlay]
  LocalClose[Close for me] --> Hidden[Local hidden state]
  Hidden --> Layout[Meeting app visibility]
  SharedClose[Close for everyone] --> Server[Shared app close]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart LR
  Pointer[Pointer move] --> Canvas[Whiteboard canvas]
  Canvas --> Engine[Tool engine]
  Engine --> Bounds[Prospective bounds]
  Bounds --> Snap[Snap adjustment]
  Snap --> Document[Yjs element update]
  Snap --> Guides[Guide overlay]
  LocalClose[Close for me] --> Hidden[Local hidden state]
  Hidden --> Layout[Meeting app visibility]
  SharedClose[Close for everyone] --> Server[Shared app close]
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (1): Last reviewed commit: "feat(whiteboard): remap tool hotkeys, pe..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

pj4real added 3 commits July 12, 2026 13:01
…dd object snapping

- Laser pointer now tracks the cursor as a plain dot while hovering and
  only paints a fading trail while the button is held; releasing lets
  the trail decay on its own instead of vanishing instantly. The trail
  is also rendered as a handful of smoothed, independently-faded bands
  instead of one straight segment per point pair.
- Add Figma-style alignment guides: dragging or resizing a shape now
  snaps to nearby elements' edges/centers and draws a guide line while
  the gesture is active.

Closes ACM-VIT#190, ACM-VIT#191
Each tool button now shows its keyboard shortcut as a small persistent
letter badge (Excalidraw-style) instead of only a hover tooltip.
…or labels

- Tool hotkeys are now letter mnemonics (V/H/P/E/R/O/L/A/T/S/K) with
  1-9 kept as a secondary alias in the original order.
- Add a corner close control on the whiteboard: everyone can hide it
  just for themselves (with a way to bring it back while it's still
  running), and admins additionally get a "close for everyone" option.
  This required a new local-only isHiddenForMe/hideForMe/showForMe
  state on AppsProvider, a "Show whiteboard" control (not admin-gated)
  in the overflow menu, and fixing the Apps panel so it lets anyone
  rejoin an app they hid instead of silently closing it for everyone.
- Remote cursor labels now show first name only, to stay compact next
  to the pointer.
Comment on lines +293 to +294
this.snapGuides = adjustment.guides;
const next = translateElement(element, dx + adjustment.dx, dy + adjustment.dy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Snapped Drag Cannot Detach Gradually

After an element snaps, the next candidate starts from that already-snapped position but uses only the latest raw pointer delta. Moving away by less than the threshold per frame therefore snaps the element back on every move, so it remains stuck until one pointer event crosses the full threshold. The candidate position needs to come from an unsnapped drag origin or account for the previous snap offset.

Artifacts

Repro: executable TypeScript harness using the real whiteboard tool engine and Yjs document helpers

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: runtime trace showing raw pointer deltas, cumulative movement, bounds, snap guides, and the passing bug assertion

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +1004 to +1007
if (tool === "select") {
snapGuidesRef.current = engineRef.current?.getSnapGuides() ?? [];
paintSnapGuides();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pointer Cancellation Leaves Snap State

A pointercancel during a snapped move or resize never reaches the new pointer-up cleanup. Touch interruption or lost pointer capture can therefore leave the guide overlay visible and the engine or resize session active, causing the next gesture to continue stale state instead of starting cleanly.

Fix in Codex Fix in Claude Code Fix in Cursor

@theg1239 theg1239 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: laser dot/trail, object snapping, letter hotkeys, per-user close

Nice PR. The scope is well chosen, the code is clean and commented, and several details are done carefully: the laser wire format stays compatible in both directions (old clients parsing only pts still render trails from new senders, and new clients handle old senders with no dot), the new hotkeys inherit the existing editable-target and modifier guards plus event.code handling for non-QWERTY layouts, and the close-for-me state is local-only with a sensible reset when the active app changes. The AppsPanel fix (non-admins were fully locked out, and an admin rejoining would have closed the app for everyone) is a real catch.

I verified everything below against the PR head (ada6488). One issue should block merge; the rest are quick.

Must fix

1. Snapped elements get stuck: slow drags can never detach

packages/apps-sdk/src/apps/whiteboard/core/tools/engine.ts:277-299

Each move computes prospective = currentElementPosition + rawPerFrameDelta. Once the element has snapped, currentElementPosition is the snapped position, so the candidate is anchored to the snap target rather than to the cursor. If every per-frame delta stays under the threshold (8 screen px / scale), the element re-snaps on every frame and never escapes, no matter how far the cursor has actually traveled.

Because the move path is rAF-coalesced, per-frame deltas are per-16.7ms deltas: at 60 Hz you need a sustained drag faster than roughly 480 px/s to break out. Fast flicks escape; slow, precise drags (exactly the case where a user is trying to place something a few pixels away from another element) stay pinned forever, and the cursor-to-element offset grows the whole time, so when it finally escapes it jumps.

Suggested fix: at drag start, store the grabbed element's start bounds next to the existing dragStart pointer origin. On each move, compute the cumulative cursor delta from dragStart, derive the candidate from startBounds + cumulativeDelta, snap that, and write the result. That makes detachment follow the true cursor position and eliminates the drift by construction, which also makes the "keep lastPoint raw" workaround comment unnecessary.

Should fix

2. No onPointerCancel handler, so a cancelled gesture strands live state

packages/apps-sdk/src/apps/whiteboard/web/components/WhiteboardCanvas.tsx:1640-1643

The canvas wires onPointerDown/Move/Up/Leave and uses pointer capture, so pointercancel (touch interrupted by a system gesture, palm rejection, capture loss) is the one terminal event with no handler. handlePointerMove checks resizeSessionRef.current unconditionally, so after a cancel the next hover continues resizing with no button held, and the new snap-guide overlay stays painted. The stale-session half predates this PR, but the PR touches exactly this code and adds the visible symptom; onPointerCancel={handlePointerUp} covers every path (pan, laser, rotate, resize, engine) since handlePointerLeave already delegates the same way.

3. Digit hotkeys 8/9 silently changed meaning, and sticky lost its binding

WhiteboardWebApp.tsx keymaps: previously 8 = text and 9 = sticky; now 8 = arrow, 9 = text, and sticky has no digit (or numpad) binding at all. The PR description and the code comment both say numbers 1-9 are kept "in the original order", which reads as unchanged, but two of nine digits were remapped and one tool dropped out. Either keep 8/9 as they were and let arrow stay letter-only, or update the comment and PR text to say the aliases follow toolbar order and note that sticky is now S only.

Recommended

  • Exclude freehand strokes from snap candidates, or cache bounds. Every move frame maps all other elements through getBoundsForElement, which is O(points) per stroke with four array allocations and spread into Math.min/max (which can throw RangeError on very long strokes; pre-existing, but this puts it on the hottest path). On a busy board this is O(total stroke points) per frame during drag and resize. Snapping to a scribble's invisible bounding box also feels random compared to snapping to shapes, stickies, and text. Filtering candidates to shape/sticky/text fixes both at once; a per-element bounds cache is a further option.
  • Clear the guide overlay when the tool changes. engine.setTool clears its guide array, but nothing repaints the overlay: switch tools via keyboard mid-drag and the last guide lines stay on screen until some later select-move or resize repaints. A small effect on tool that empties snapGuidesRef and calls paintSnapGuides() covers it.
  • rAF-coalesce the resize path. Element moves go through queuePointerMove, but the resize branch in handlePointerMove runs per raw pointer event (up to 240 Hz on some devices), each time doing getPageElements plus a full bounds map plus an overlay repaint. Reusing the same coalescing as moves keeps the cost bounded.
  • Consider a hold-to-disable-snapping modifier. Figma and Excalidraw use Cmd/Ctrl while dragging to bypass snapping; since the threshold is fixed, precise placement near another element otherwise fights the magnet. Pairs naturally with the fix for issue 1.

Minor

  • The laser dot vanishes abruptly when its 500 ms TTL expires (paintLaserFrame drops it and draws at full alpha until then). A short fade over the last ~150 ms would match the trail's behavior. Purely polish.
  • When min-size clamping engages during a resize, the snapped edge can miss the target while the guide still draws as if aligned, since the snap offset is applied to the pointer and then re-clamped. Edge case, fine to leave.
  • Snapping and guides use axis-aligned bounds, so rotated elements snap on their unrotated box and guides may not visually touch them. Worth a code comment as a known limitation.
  • Two people with the same first name now render identical cursor labels. Deliberate tradeoff for compactness, just noting it.

Tests

snapping.ts is a pure, well-documented module and an ideal first unit-test target for apps-sdk (the package has no test setup today, but meeting-core already has a vitest precedent in this repo). A table test over edge/center matches, threshold boundaries, and the per-axis independence would have caught nothing here, but the issue-1 fix (cumulative-delta candidate) is exactly the kind of logic a small engine test protects going forward.

Summary

Fix issue 1 before merge; 2 and 3 are small and worth doing in the same pass. Everything else can follow. The feature set itself is in good shape, and the manual two-session test plan in the description is appreciated.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants