diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 88e35f3..799faa9 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -72,6 +72,9 @@ export const MinesweeperPanel = () => { const canvasRef = useRef(null); const rfbRef = useRef(null); const buttonsRef = useRef(0); + // Every keysym we have sent a press for and not yet a release. A key held when focus leaves never gets + // its keyup, and a key stuck down on an X server stays stuck until something releases it. + const heldKeysRef = useRef(new Set()); const boxRef = useRef(null); @@ -220,6 +223,7 @@ export const MinesweeperPanel = () => { rfbRef.current = rfb; return () => { + releaseHeldKeys(); rfb.close(); rfbRef.current = null; setConnected(false); @@ -250,9 +254,28 @@ export const MinesweeperPanel = () => { const keysym = keysymFor(ev.key); if (keysym === null) return; ev.preventDefault(); + if (down) heldKeysRef.current.add(keysym); + else heldKeysRef.current.delete(keysym); rfb.key(keysym, down); }; + /** + * Release everything on the way out. + * + * A key held when the canvas loses focus never sends its keyup, and the server has no way to know: it + * stays down until something says otherwise. That is not a cosmetic leak — a stranded modifier turned + * every left click into a shift-click, which Minesweeper reads as a right click, and the board flagged + * instead of opening. + * + * Modifiers are no longer sent at all (see `keysymFor`), so this now covers the ordinary keys. Cheap, + * and the failure it prevents is invisible from the panel. + */ + const releaseHeldKeys = () => { + const rfb = rfbRef.current; + for (const keysym of heldKeysRef.current) rfb?.key(keysym, false); + heldKeysRef.current.clear(); + }; + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { const rfb = rfbRef.current; if (!rfb) return; @@ -314,6 +337,7 @@ export const MinesweeperPanel = () => { onMouseMove={(ev) => onPointer(ev, 'move')} onKeyDown={(ev) => onKey(ev, true)} onKeyUp={(ev) => onKey(ev, false)} + onBlur={releaseHeldKeys} onContextMenu={(ev) => ev.preventDefault()} // Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than // the screen. It is half of what keeps this sharp; `stage` above is the other half. diff --git a/web/rfb.ts b/web/rfb.ts index 52bd01a..1814c91 100644 --- a/web/rfb.ts +++ b/web/rfb.ts @@ -380,13 +380,24 @@ const NAMED_KEYSYMS: Record = { ArrowRight: 0xff53, ArrowDown: 0xff54, Insert: 0xff63, - Shift: 0xffe1, - Control: 0xffe3, - Alt: 0xffe9, - Meta: 0xffeb, - CapsLock: 0xffe5, }; +/* + MODIFIERS ARE DELIBERATELY ABSENT, AND THAT IS A FIX RATHER THAN AN OMISSION. + + They were here, and it stranded `Shift_L` in the down state on the server: press shift, release it while + the canvas no longer has focus, and the keyup never arrives. Every left click then reached the game as + shift-click, which reads as a right click — the board flagged instead of opening and nothing about the + panel looked wrong. + + They are not needed. `ev.key` is the character the layout ALREADY produced, so a shifted key arrives as + `A` and x11vnc presses shift itself to type it. Sending our own shift on top was redundant on the good + path and load-bearing on the bad one. + + `keysymFor` returning null for them also means they stay the browser's, which is what you want: shift-tab + should still move focus out of a game of Minesweeper. +*/ + export function keysymFor(key: string): number | null { const named = NAMED_KEYSYMS[key]; if (named !== undefined) return named;