Skip to content

fix(webui): broaden escape overlay guard + restore two-step cancel - #246

Merged
winlp4ever merged 3 commits into
mainfrom
fix/canvas-escape-overlay-and-two-step
Aug 23, 2026
Merged

fix(webui): broaden escape overlay guard + restore two-step cancel#246
winlp4ever merged 3 commits into
mainfrom
fix/canvas-escape-overlay-and-two-step

Conversation

@winlp4ever

@winlp4ever winlp4ever commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes two regressions in the board Escape handler introduced by #238, reported as #244 and #245.

#244 — Escape on non-menu/dialog overlays also switches tool to select

The focused-overlay DOM guard only matched [role='menu'],[role='dialog'],[role='alertdialog']. Radix Select / combobox / listbox / popover content isn't any of those, so dismissing one with Escape (while a create tool was active) also reset the tool.

Fix: broaden the guard to [role='listbox'] and [data-radix-popper-content-wrapper] — the generic wrapper Radix renders around all popper-based content (Select, Combobox, Popover, Tooltip, Dropdown). This also makes #245's stopPropagation safe: an overlay can't slip past the guard and be denied its close.

#245 — single Escape both switches tool and clears selection

The capture-phase handler called setTool('select') without stopPropagation(), so canvas-harness's own bubble-phase Escape handler still ran and cleared the selection / aborted the draw on the same press. No two-step parity, despite the comment claiming it.

The library reads tool via a ref (no reset on tool change) and couples abort + deselect into its one Escape handler, so the switch alone can't cancel a draw and I can't ask the library to abort-without-deselecting.

Fix — restore the tldraw/excalidraw two-step:

  • 1st Escape (create tool active): switch to select, abort the in-progress draft ourselves via store.resetInteractionState(), and stopPropagation() to suppress the library's coupled deselect → selection preserved.
  • 2nd Escape (tool already select): fall through to the library → deselect.

Refactor + tests

The Escape branch logic had two fix rounds while untested. Extracted the decision into a pure decideBoardEscape(e, app, { isTyping, insideOverlayDom }) returning no-op | defer-to-overlay | switch-to-select; the window listener now just gathers the two DOM facts and applies the outcome. Added use-board-keyboard.test.ts (8 cases): core switch, the two-step "already select" no-op, modifier/typing/off-board guards, and every overlay-defer path.

Testing

  • npm run check-all (type-check + eslint) + the new unit tests pass clean.
  • Manual: create tool + selection → 1st Escape switches to select & keeps selection, 2nd Escape deselects; mid-draw + Escape → draft aborted, tool→select; open a Select/Popover/Combobox + Escape → overlay closes, tool untouched.

Notes


Closes #244
Closes #245

Two regressions from the escape-to-select handler (#244, #245):

- #244: the focused-overlay DOM guard only matched menu/dialog/alertdialog, so
  dismissing a Radix Select/combobox/listbox/popover with Escape also yanked the
  tool to select. Broaden it to [role='listbox'] and the generic
  [data-radix-popper-content-wrapper] that wraps all Radix popper content.

- #245: the capture-phase handler switched the tool without stopPropagation, so
  canvas-harness's bubble-phase Escape also ran and cleared the selection /
  aborted the draw on the same press — no two-step parity. The lib reads the
  tool via a ref with no reset on change and couples abort+deselect, so on the
  first Escape we now switch to select, abort the in-progress draft ourselves via
  resetInteractionState(), and stopPropagation to suppress the lib's coupled
  deselect (selection preserved). A second Escape (tool already select) falls
  through to the lib and deselects.

Fixes #244
Fixes #245
The Escape branch logic had two fix rounds while untested. Pull the decision
(no-op / defer-to-overlay / switch-to-select) out of the window listener into a
pure decideBoardEscape(e, app, {isTyping, insideOverlayDom}); the handler now
just gathers the two DOM facts and applies the outcome. Add use-board-keyboard.test.ts
covering the core switch, the two-step 'already select' no-op, modifier/typing/
off-board guards, and every overlay-defer path (store flags + focused-overlay DOM).
…eAppState

Review follow-ups on the capture-phase Escape listener:

- Restore the early `if (e.key !== 'Escape') return` in onEscape. The refactor
  moved the key check into decideBoardEscape, so the window-wide capture listener
  paid a 5-selector `closest()` walk + getState() on every keystroke. Bail first.
- Type EscapeAppState as Pick<BoardAppState, ...> instead of a hand-rolled loose
  subset, so `viewMode !== 'board'` / `tool !== 'select'` stay checked against the
  store's real unions and can't silently drift.
@winlp4ever
winlp4ever merged commit 21c7781 into main Aug 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant