Skip to content

Commit 2d51569

Browse files
KealanAUPupilTong
andauthored
feat(web-core): implement keyboard event support (#2594)
## Summary Implements keyboard event support (`keydown` / `keyup`) for the Lynx web platform. ### What changed **`packages/web-platform/web-core/src/constants.rs`** Added `keydown` and `keyup` to `ELEMENT_REACTIVE_EVENTS` so per-element event hooks are triggered when keyboard handlers are registered. **`packages/web-platform/web-core/ts/types/EventType.ts`** Extended `MinimalRawEventObject` with keyboard fields: `key`, `code`, `keyCode`, `shiftKey`, `altKey`, `ctrlKey`, `metaKey`. **`packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createCrossThreadEvent.ts`** Added a `keydown` / `keyup` branch that forwards all keyboard properties into the cross-thread event object so they arrive in the React handler. **`packages/web-platform/web-core/ts/client/mainthread/elementAPIs/WASMJSBinding.ts`** — two fixes: 1. **Shadow DOM routing**: `rootDom` is a `ShadowRoot`. Keyboard events fired on focused elements outside the shadow root never propagate into it, so `rootDom.addEventListener('keydown', ...)` never fires. `keydown` and `keyup` are now attached to `document` instead, with capture phase, so they fire regardless of which element has focus. https://github.com/lynx-family/lynx/blob/be387c0b9fa50af9424a353dfb77f575eac6c2be/core/renderer/dom/fragment/event/platform_event_handler.cc#L78 Is marked as todo 2. **Empty bubble path crash**: For `global-bindkeydown`, the DOM event originates outside the Lynx element tree. The bubble path is empty, so Rust passes `target_unique_id = 0`. `getElementByUniqueId(0)` returns `undefined`, and `generateTargetObject(undefined, ...)` throws a `TypeError` that is silently swallowed at the WASM boundary, dropping the event. Fixed by falling back to `currentTarget` (the element that registered the global handler) when `target` resolves to `undefined`. **`packages/web-platform/web-core/tests/element-apis.spec.ts`** Added unit tests for keyboard property forwarding in `createCrossThreadEvent`. **`examples/react/src/KeyboardDemo.tsx` + `keyboard.css`** Visual QWERTY keyboard demo that highlights keys on press using `global-bindkeydown` / `global-bindkeyup`. Green for regular keys, cyan for modifiers. ### Scope — web platform only This PR covers the web platform runtime. Keyboard events **do not work on native iOS/Android** because the native Lynx runtime has no code path to forward `UIKeyboardEvent` (iOS) or `KeyEvent` (Android) into the Lynx JS layer. Native support is being tracked upstream in `lynx-family/lynx`: - [#6527 — Track CDP text input support in Lynx DevTool](lynx-family/lynx#6527) - [#6529 — Support Input.dispatchKeyEvent in Lynx DevTool CDP](lynx-family/lynx#6529) Once those land, the event pipeline added here should work end-to-end on device without further changes to `lynx-stack`. ### How to test ```bash # Build the web platform cd packages/web-platform/web-core && npx tsc --build tsconfig.json pnpm turbo build --filter=@lynx-js/web-rsbuild-server-middleware --force # Run the dev server cd examples/react && pnpm dev ``` Open `http://localhost:<port>/__web_preview?casename=main.web.bundle` and press keys — the keyboard visualiser lights up. ## Checklist - [x] Tests updated - [ ] Documentation updated (not required — internal web platform detail) - [x] Changeset added https://github.com/user-attachments/assets/a4fe1d52-aff9-4a20-ab4a-c6c4cecec7c0 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Global keyboard event support (keydown, keyup) with key, code, keyCode and modifier flags. * **Bug Fixes** * Document-level keyboard listeners now attach correctly and are reliably removed during disposal to prevent stray events. * **Tests** * Unit and E2E tests added to verify keyboard event serialization, forwarding, global vs non-global behavior, and modifier handling. * **Documentation / Examples** * Interactive keyboard demo app with styling and example project/config added. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/lynx-family/lynx-stack/pull/2594) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Haoyang Wang <12288479+PupilTong@users.noreply.github.com>
1 parent 734f94d commit 2d51569

24 files changed

Lines changed: 723 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@lynx-js/web-core": minor
3+
---
4+
5+
feat: support global keyboard events (keydown/keyup) on web
6+
7+
Register `keydown`/`keyup` listeners on `document` instead of the ShadowRoot, which never receives keyboard events. Handle the case where `target_unique_id` is 0 (no element in the Lynx tree) by falling back to `currentTarget`, enabling `global-bindkeydown` and `global-bindkeyup` to work correctly in web previews.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin';
2+
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
3+
import { defineConfig } from '@lynx-js/rspeedy';
4+
5+
const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS'];
6+
7+
export default defineConfig({
8+
plugins: [
9+
pluginReactLynx(),
10+
pluginQRCode({
11+
schema(url) {
12+
// We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode
13+
return `${url}?fullscreen=true`;
14+
},
15+
}),
16+
],
17+
environments: {
18+
web: {},
19+
lynx: {
20+
performance: {
21+
profile: enableBundleAnalysis,
22+
},
23+
},
24+
},
25+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@lynx-js/example-react-keyboard",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "rspeedy build",
8+
"dev": "rspeedy dev"
9+
},
10+
"dependencies": {
11+
"@lynx-js/react": "workspace:*"
12+
},
13+
"devDependencies": {
14+
"@lynx-js/preact-devtools": "^5.0.1",
15+
"@lynx-js/qrcode-rsbuild-plugin": "workspace:*",
16+
"@lynx-js/react-rsbuild-plugin": "workspace:*",
17+
"@lynx-js/rspeedy": "workspace:*",
18+
"@lynx-js/types": "3.7.0",
19+
"@types/react": "^18.3.28"
20+
}
21+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { KeyboardDemo } from './KeyboardDemo.js';
2+
3+
export function App() {
4+
return <KeyboardDemo />;
5+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { useState } from '@lynx-js/react';
2+
import type { BaseKeyEvent } from '@lynx-js/types';
3+
4+
import './keyboard.css';
5+
6+
type LynxKeyEvent = BaseKeyEvent<unknown> & { code?: string };
7+
8+
interface KeyDef {
9+
code: string;
10+
label: string;
11+
flex: number;
12+
}
13+
14+
const ROW1: KeyDef[] = [
15+
{ code: 'Backquote', label: '`', flex: 1 },
16+
{ code: 'Digit1', label: '1', flex: 1 },
17+
{ code: 'Digit2', label: '2', flex: 1 },
18+
{ code: 'Digit3', label: '3', flex: 1 },
19+
{ code: 'Digit4', label: '4', flex: 1 },
20+
{ code: 'Digit5', label: '5', flex: 1 },
21+
{ code: 'Digit6', label: '6', flex: 1 },
22+
{ code: 'Digit7', label: '7', flex: 1 },
23+
{ code: 'Digit8', label: '8', flex: 1 },
24+
{ code: 'Digit9', label: '9', flex: 1 },
25+
{ code: 'Digit0', label: '0', flex: 1 },
26+
{ code: 'Minus', label: '-', flex: 1 },
27+
{ code: 'Equal', label: '=', flex: 1 },
28+
{ code: 'Backspace', label: '⌫', flex: 2 },
29+
];
30+
31+
const ROW2: KeyDef[] = [
32+
{ code: 'Tab', label: 'Tab', flex: 1.5 },
33+
{ code: 'KeyQ', label: 'Q', flex: 1 },
34+
{ code: 'KeyW', label: 'W', flex: 1 },
35+
{ code: 'KeyE', label: 'E', flex: 1 },
36+
{ code: 'KeyR', label: 'R', flex: 1 },
37+
{ code: 'KeyT', label: 'T', flex: 1 },
38+
{ code: 'KeyY', label: 'Y', flex: 1 },
39+
{ code: 'KeyU', label: 'U', flex: 1 },
40+
{ code: 'KeyI', label: 'I', flex: 1 },
41+
{ code: 'KeyO', label: 'O', flex: 1 },
42+
{ code: 'KeyP', label: 'P', flex: 1 },
43+
{ code: 'BracketLeft', label: '[', flex: 1 },
44+
{ code: 'BracketRight', label: ']', flex: 1 },
45+
{ code: 'Backslash', label: '\\', flex: 1.5 },
46+
];
47+
48+
const ROW3: KeyDef[] = [
49+
{ code: 'CapsLock', label: 'Caps', flex: 1.75 },
50+
{ code: 'KeyA', label: 'A', flex: 1 },
51+
{ code: 'KeyS', label: 'S', flex: 1 },
52+
{ code: 'KeyD', label: 'D', flex: 1 },
53+
{ code: 'KeyF', label: 'F', flex: 1 },
54+
{ code: 'KeyG', label: 'G', flex: 1 },
55+
{ code: 'KeyH', label: 'H', flex: 1 },
56+
{ code: 'KeyJ', label: 'J', flex: 1 },
57+
{ code: 'KeyK', label: 'K', flex: 1 },
58+
{ code: 'KeyL', label: 'L', flex: 1 },
59+
{ code: 'Semicolon', label: ';', flex: 1 },
60+
{ code: 'Quote', label: '\'', flex: 1 },
61+
{ code: 'Enter', label: '↵ Enter', flex: 2.25 },
62+
];
63+
64+
const ROW4: KeyDef[] = [
65+
{ code: 'ShiftLeft', label: '⇧ Shift', flex: 2.25 },
66+
{ code: 'KeyZ', label: 'Z', flex: 1 },
67+
{ code: 'KeyX', label: 'X', flex: 1 },
68+
{ code: 'KeyC', label: 'C', flex: 1 },
69+
{ code: 'KeyV', label: 'V', flex: 1 },
70+
{ code: 'KeyB', label: 'B', flex: 1 },
71+
{ code: 'KeyN', label: 'N', flex: 1 },
72+
{ code: 'KeyM', label: 'M', flex: 1 },
73+
{ code: 'Comma', label: ',', flex: 1 },
74+
{ code: 'Period', label: '.', flex: 1 },
75+
{ code: 'Slash', label: '/', flex: 1 },
76+
{ code: 'ShiftRight', label: 'Shift ⇧', flex: 2.75 },
77+
];
78+
79+
const ROW5: KeyDef[] = [
80+
{ code: 'ControlLeft', label: 'Ctrl', flex: 1.5 },
81+
{ code: 'MetaLeft', label: '⌘', flex: 1.25 },
82+
{ code: 'AltLeft', label: 'Alt', flex: 1.25 },
83+
{ code: 'Space', label: '', flex: 6 },
84+
{ code: 'AltRight', label: 'Alt', flex: 1.25 },
85+
{ code: 'MetaRight', label: '⌘', flex: 1.25 },
86+
{ code: 'ControlRight', label: 'Ctrl', flex: 1.5 },
87+
];
88+
89+
const ROWS = [ROW1, ROW2, ROW3, ROW4, ROW5];
90+
91+
const MODIFIER_CODES = new Set([
92+
'ShiftLeft',
93+
'ShiftRight',
94+
'ControlLeft',
95+
'ControlRight',
96+
'AltLeft',
97+
'AltRight',
98+
'MetaLeft',
99+
'MetaRight',
100+
'CapsLock',
101+
]);
102+
103+
const REGULAR_COLOR = '#2a2a2a';
104+
const LIT_COLOR = '#39ff14';
105+
const MOD_COLOR = '#00d4ff';
106+
107+
export function KeyboardDemo() {
108+
const [pressed, setPressed] = useState<ReadonlySet<string>>(new Set());
109+
const [lastKey, setLastKey] = useState('—');
110+
111+
const handleKeyDown = (e: LynxKeyEvent) => {
112+
'background-only';
113+
const code = e.code ?? '';
114+
setLastKey(e.key);
115+
setPressed(prev => new Set([...prev, code]));
116+
};
117+
118+
const handleKeyUp = (e: LynxKeyEvent) => {
119+
'background-only';
120+
const code = e.code ?? '';
121+
setPressed(prev => {
122+
const next = new Set(prev);
123+
next.delete(code);
124+
return next;
125+
});
126+
};
127+
128+
return (
129+
<view
130+
className='kb-root'
131+
{...{
132+
'global-bindkeydown': handleKeyDown,
133+
'global-bindkeyup': handleKeyUp,
134+
}}
135+
>
136+
{/* Header */}
137+
<view className='kb-header'>
138+
<text style={{ color: '#fff', fontSize: '22px', fontWeight: 'bold' }}>
139+
KEYBOARD EVENTS
140+
</text>
141+
<text style={{ color: '#555', fontSize: '13px' }}>
142+
Click here, then press keys
143+
</text>
144+
</view>
145+
146+
{/* Last key */}
147+
<view className='kb-last-key-row'>
148+
<text style={{ color: '#555', fontSize: '13px', flex: 1 }}>
149+
Last key
150+
</text>
151+
<text
152+
style={{
153+
color: pressed.size > 0 ? LIT_COLOR : '#444',
154+
fontSize: '26px',
155+
fontWeight: 'bold',
156+
}}
157+
>
158+
{lastKey}
159+
</text>
160+
</view>
161+
162+
{/* Keyboard */}
163+
<view className='kb-board'>
164+
{ROWS.map((row, rowIdx) => (
165+
<view key={rowIdx} className='kb-row'>
166+
{row.map((key) => {
167+
const isPressed = pressed.has(key.code);
168+
const isMod = MODIFIER_CODES.has(key.code);
169+
const bg = isPressed
170+
? (isMod ? MOD_COLOR : LIT_COLOR)
171+
: REGULAR_COLOR;
172+
const labelColor = isPressed ? '#000' : '#bbb';
173+
const isSpace = key.code === 'Space';
174+
175+
return (
176+
<view
177+
key={key.code}
178+
className={isSpace ? 'kb-key kb-space' : 'kb-key'}
179+
style={{ flex: key.flex, backgroundColor: bg }}
180+
>
181+
{!isSpace && (
182+
<text
183+
style={{
184+
color: labelColor,
185+
fontSize: key.label.length > 3 ? '9px' : '12px',
186+
fontWeight: isPressed ? 'bold' : 'normal',
187+
textAlign: 'center',
188+
}}
189+
>
190+
{key.label}
191+
</text>
192+
)}
193+
</view>
194+
);
195+
})}
196+
</view>
197+
))}
198+
</view>
199+
200+
{/* Legend */}
201+
<view className='kb-legend'>
202+
<view className='kb-legend-item'>
203+
<view
204+
style={{
205+
width: '12px',
206+
height: '12px',
207+
backgroundColor: LIT_COLOR,
208+
borderRadius: '3px',
209+
marginRight: '6px',
210+
}}
211+
/>
212+
<text style={{ color: '#555', fontSize: '11px' }}>Key</text>
213+
</view>
214+
<view className='kb-legend-item'>
215+
<view
216+
style={{
217+
width: '12px',
218+
height: '12px',
219+
backgroundColor: MOD_COLOR,
220+
borderRadius: '3px',
221+
marginRight: '6px',
222+
}}
223+
/>
224+
<text style={{ color: '#555', fontSize: '11px' }}>Modifier</text>
225+
</view>
226+
</view>
227+
</view>
228+
);
229+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import '@lynx-js/preact-devtools';
2+
import '@lynx-js/react/debug';
3+
import { root } from '@lynx-js/react';
4+
5+
import { App } from './App.jsx';
6+
7+
root.render(
8+
<App />,
9+
);
10+
11+
if (import.meta.webpackHot) {
12+
import.meta.webpackHot.accept();
13+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
.kb-root {
2+
width: 100%;
3+
min-height: 100vh;
4+
background-color: #111;
5+
display: flex;
6+
flex-direction: column;
7+
box-sizing: border-box;
8+
padding: 20px;
9+
}
10+
11+
.kb-header {
12+
display: flex;
13+
flex-direction: column;
14+
align-items: center;
15+
padding: 20px 0 12px;
16+
}
17+
18+
.kb-last-key-row {
19+
display: flex;
20+
flex-direction: row;
21+
align-items: center;
22+
background-color: #1a1a1a;
23+
border-radius: 12px;
24+
padding: 14px 16px;
25+
margin-bottom: 16px;
26+
}
27+
28+
.kb-board {
29+
background-color: #1a1a1a;
30+
border-radius: 16px;
31+
padding: 12px;
32+
display: flex;
33+
flex-direction: column;
34+
}
35+
36+
.kb-row {
37+
display: flex;
38+
flex-direction: row;
39+
margin-bottom: 6px;
40+
}
41+
42+
.kb-row:last-child {
43+
margin-bottom: 0;
44+
}
45+
46+
.kb-key {
47+
display: flex;
48+
align-items: center;
49+
justify-content: center;
50+
border-radius: 6px;
51+
height: 38px;
52+
margin-left: 4px;
53+
}
54+
55+
.kb-key:first-child {
56+
margin-left: 0;
57+
}
58+
59+
.kb-space {
60+
height: 40px;
61+
}
62+
63+
.kb-legend {
64+
display: flex;
65+
flex-direction: row;
66+
justify-content: center;
67+
padding: 14px 0;
68+
}
69+
70+
.kb-legend-item {
71+
display: flex;
72+
flex-direction: row;
73+
align-items: center;
74+
margin: 0 12px;
75+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="@lynx-js/rspeedy/client" />

0 commit comments

Comments
 (0)