Skip to content

Commit 9fe284a

Browse files
authored
feat(webview): synthesize keyboard and mouse input (microsoft#41055)
1 parent bc29607 commit 9fe284a

7 files changed

Lines changed: 388 additions & 123 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
type Modifiers = {
18+
ctrlKey: boolean;
19+
shiftKey: boolean;
20+
altKey: boolean;
21+
metaKey: boolean;
22+
};
23+
24+
export type KeyEventParams = Modifiers & {
25+
code: string;
26+
key: string;
27+
keyCode: number;
28+
location: number;
29+
repeat: boolean;
30+
// Present for printable keys; absent for non-text keys (arrows, modifiers, etc.).
31+
text?: string;
32+
};
33+
34+
export type MouseEventParams = Modifiers & {
35+
type: 'mousedown' | 'mouseup' | 'click' | 'auxclick' | 'dblclick' | 'contextmenu';
36+
x: number;
37+
y: number;
38+
button: number;
39+
buttons: number;
40+
clickCount: number;
41+
};
42+
43+
export type MouseMoveParams = Modifiers & {
44+
x: number;
45+
y: number;
46+
button: number;
47+
buttons: number;
48+
};
49+
50+
export type WheelParams = Modifiers & {
51+
x: number;
52+
y: number;
53+
deltaX: number;
54+
deltaY: number;
55+
};
56+
57+
export type TapParams = Modifiers & {
58+
x: number;
59+
y: number;
60+
};
61+
62+
const kTrustedSynthetic = '__pwTrustedSynthetic';
63+
64+
function markAndDispatch(node: EventTarget, event: Event): boolean {
65+
Object.defineProperty(event, kTrustedSynthetic, { value: true });
66+
return node.dispatchEvent(event);
67+
}
68+
69+
// Legacy WebKit-only KeyboardEvent.keyIdentifier (a DOM Level 3 draft property
70+
// dropped by every other engine). It cannot be supplied via the constructor, so
71+
// compute it from the virtual key code and define it on the event before
72+
// dispatch. Mirrors WebCore's keyIdentifierForWindowsKeyCode.
73+
const kNamedKeyIdentifiers: Record<number, string> = {
74+
8: 'U+0008', // Backspace
75+
9: 'U+0009', // Tab
76+
13: 'Enter',
77+
16: 'Shift',
78+
17: 'Control',
79+
18: 'Alt',
80+
27: 'U+001B', // Escape
81+
33: 'PageUp',
82+
34: 'PageDown',
83+
35: 'End',
84+
36: 'Home',
85+
37: 'Left',
86+
38: 'Up',
87+
39: 'Right',
88+
40: 'Down',
89+
45: 'Insert',
90+
46: 'U+007F', // Delete
91+
};
92+
93+
function keyIdentifierFor(keyCode: number, key: string): string {
94+
const named = kNamedKeyIdentifiers[keyCode];
95+
if (named !== undefined)
96+
return named;
97+
if (keyCode >= 112 && keyCode <= 135)
98+
return 'F' + (keyCode - 111);
99+
if (key.length === 1)
100+
return 'U+' + key.toUpperCase().charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
101+
return '';
102+
}
103+
104+
function dispatchKeyEvent(node: EventTarget, type: string, init: KeyboardEventInit, keyCode: number, key: string): boolean {
105+
const event = new KeyboardEvent(type, init);
106+
Object.defineProperty(event, 'keyIdentifier', { value: keyIdentifierFor(keyCode, key), configurable: true });
107+
return markAndDispatch(node, event);
108+
}
109+
110+
export class WebViewInput {
111+
private _window: Window & typeof globalThis;
112+
private _document: Document;
113+
private _hoverTarget: Element | null = null;
114+
115+
constructor(window: Window & typeof globalThis, document: Document) {
116+
this._window = window;
117+
this._document = document;
118+
}
119+
120+
// Descend through open shadow roots so synthetic events land on the actual
121+
// element under the pointer rather than on the shadow host.
122+
private _deepElementFromPoint(x: number, y: number): Element | null {
123+
let el = this._document.elementFromPoint(x, y);
124+
while (el && el.shadowRoot) {
125+
const inner = el.shadowRoot.elementFromPoint(x, y);
126+
if (!inner || inner === el)
127+
break;
128+
el = inner;
129+
}
130+
return el;
131+
}
132+
133+
// The focused element may live inside one or more shadow roots, where
134+
// document.activeElement only reports the outermost shadow host.
135+
private _deepActiveElement(): Element | null {
136+
let active = this._document.activeElement;
137+
while (active && active.shadowRoot && active.shadowRoot.activeElement)
138+
active = active.shadowRoot.activeElement;
139+
return active;
140+
}
141+
142+
private _insertText(target: Element | null, text: string) {
143+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
144+
const start = target.selectionStart ?? target.value.length;
145+
const end = target.selectionEnd ?? target.value.length;
146+
target.value = target.value.slice(0, start) + text + target.value.slice(end);
147+
const pos = start + text.length;
148+
try {
149+
target.setSelectionRange(pos, pos);
150+
} catch {
151+
}
152+
target.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: false, data: text, inputType: 'insertText' }));
153+
} else if (target && (target as HTMLElement).isContentEditable) {
154+
this._document.execCommand('insertText', false, text);
155+
}
156+
}
157+
158+
keydown(params: KeyEventParams) {
159+
const target = this._deepActiveElement() || this._document.body;
160+
if (!target)
161+
return;
162+
const init: KeyboardEventInit = {
163+
bubbles: true,
164+
cancelable: true,
165+
view: this._window,
166+
code: params.code,
167+
key: params.key,
168+
keyCode: params.keyCode,
169+
which: params.keyCode,
170+
location: params.location,
171+
repeat: params.repeat,
172+
ctrlKey: params.ctrlKey,
173+
shiftKey: params.shiftKey,
174+
altKey: params.altKey,
175+
metaKey: params.metaKey,
176+
};
177+
const notPrevented = dispatchKeyEvent(target, 'keydown', init, params.keyCode, params.key);
178+
if (params.text === undefined)
179+
return;
180+
const charCode = params.text.charCodeAt(0);
181+
const charNotPrevented = markAndDispatch(target, new KeyboardEvent('keypress', { ...init, charCode, keyCode: charCode, which: charCode }));
182+
if (!notPrevented || !charNotPrevented)
183+
return;
184+
// Real WebKit fires a `textInput` (TextEvent) whose default action performs
185+
// the insertion (and the subsequent beforeinput/input). Replicate it; the
186+
// event's default does the insertion, so we do not insert manually. Enter's
187+
// text is '\r' but the inserted/textInput data is a newline.
188+
this._dispatchTextInput(target, params.text === '\r' ? '\n' : params.text);
189+
}
190+
191+
private _dispatchTextInput(target: EventTarget, text: string) {
192+
// TextEvent has no usable constructor in WebKit — initTextEvent is the only
193+
// way to create one (initTextEvent(type, bubbles, cancelable, view, data)).
194+
const event = this._document.createEvent('TextEvent') as any;
195+
event.initTextEvent('textInput', true, true, this._window, text);
196+
markAndDispatch(target, event);
197+
}
198+
199+
keyup(params: KeyEventParams) {
200+
const target = this._deepActiveElement() || this._document.body;
201+
if (!target)
202+
return;
203+
dispatchKeyEvent(target, 'keyup', {
204+
bubbles: true,
205+
cancelable: true,
206+
view: this._window,
207+
code: params.code,
208+
key: params.key,
209+
keyCode: params.keyCode,
210+
which: params.keyCode,
211+
location: params.location,
212+
ctrlKey: params.ctrlKey,
213+
shiftKey: params.shiftKey,
214+
altKey: params.altKey,
215+
metaKey: params.metaKey,
216+
}, params.keyCode, params.key);
217+
}
218+
219+
insertText(text: string) {
220+
this._insertText(this._deepActiveElement(), text);
221+
}
222+
223+
mouseMove(params: MouseMoveParams) {
224+
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
225+
const base: MouseEventInit = {
226+
bubbles: true,
227+
cancelable: true,
228+
view: this._window,
229+
clientX: params.x,
230+
clientY: params.y,
231+
screenX: params.x,
232+
screenY: params.y,
233+
button: params.button,
234+
buttons: params.buttons,
235+
ctrlKey: params.ctrlKey,
236+
shiftKey: params.shiftKey,
237+
altKey: params.altKey,
238+
metaKey: params.metaKey,
239+
};
240+
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
241+
const prev = this._hoverTarget;
242+
if (prev !== target) {
243+
if (prev && prev.isConnected) {
244+
markAndDispatch(prev, new PointerEvent('pointerout', { ...pointer, relatedTarget: target }));
245+
markAndDispatch(prev, new MouseEvent('mouseout', { ...base, relatedTarget: target }));
246+
markAndDispatch(prev, new PointerEvent('pointerleave', { ...pointer, bubbles: false, cancelable: false, relatedTarget: target }));
247+
markAndDispatch(prev, new MouseEvent('mouseleave', { ...base, bubbles: false, cancelable: false, relatedTarget: target }));
248+
}
249+
markAndDispatch(target, new PointerEvent('pointerover', { ...pointer, relatedTarget: prev }));
250+
markAndDispatch(target, new MouseEvent('mouseover', { ...base, relatedTarget: prev }));
251+
markAndDispatch(target, new PointerEvent('pointerenter', { ...pointer, bubbles: false, cancelable: false, relatedTarget: prev }));
252+
markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev }));
253+
this._hoverTarget = target;
254+
}
255+
markAndDispatch(target, new PointerEvent('pointermove', pointer));
256+
markAndDispatch(target, new MouseEvent('mousemove', base));
257+
}
258+
259+
mouseEvent(params: MouseEventParams) {
260+
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
261+
const event = new MouseEvent(params.type, {
262+
bubbles: true,
263+
cancelable: true,
264+
view: this._window,
265+
clientX: params.x,
266+
clientY: params.y,
267+
screenX: params.x,
268+
screenY: params.y,
269+
button: params.button,
270+
buttons: params.buttons,
271+
detail: params.clickCount,
272+
ctrlKey: params.ctrlKey,
273+
shiftKey: params.shiftKey,
274+
altKey: params.altKey,
275+
metaKey: params.metaKey,
276+
});
277+
markAndDispatch(target, event);
278+
}
279+
280+
wheel(params: WheelParams) {
281+
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
282+
const event = new WheelEvent('wheel', {
283+
bubbles: true,
284+
cancelable: true,
285+
view: this._window,
286+
clientX: params.x,
287+
clientY: params.y,
288+
screenX: params.x,
289+
screenY: params.y,
290+
deltaX: params.deltaX,
291+
deltaY: params.deltaY,
292+
deltaMode: 0,
293+
ctrlKey: params.ctrlKey,
294+
shiftKey: params.shiftKey,
295+
altKey: params.altKey,
296+
metaKey: params.metaKey,
297+
});
298+
markAndDispatch(target, event);
299+
this._window.scrollBy(params.deltaX, params.deltaY);
300+
}
301+
302+
tap(params: TapParams) {
303+
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
304+
const init: MouseEventInit = {
305+
bubbles: true,
306+
cancelable: true,
307+
view: this._window,
308+
clientX: params.x,
309+
clientY: params.y,
310+
screenX: params.x,
311+
screenY: params.y,
312+
ctrlKey: params.ctrlKey,
313+
shiftKey: params.shiftKey,
314+
altKey: params.altKey,
315+
metaKey: params.metaKey,
316+
};
317+
try {
318+
const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x, pageY: params.y, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 });
319+
markAndDispatch(target, new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] }));
320+
markAndDispatch(target, new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] }));
321+
} catch {
322+
}
323+
markAndDispatch(target, new MouseEvent('mousedown', { ...init, button: 0, buttons: 1, detail: 1 }));
324+
markAndDispatch(target, new MouseEvent('mouseup', { ...init, button: 0, buttons: 0, detail: 1 }));
325+
markAndDispatch(target, new MouseEvent('click', { ...init, button: 0, buttons: 0, detail: 1 }));
326+
}
327+
}
328+
329+
export default WebViewInput;

packages/playwright-core/src/server/webkit/DEPS.list

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@isomorphic/**
33
@utils/**
44
../
5+
../../generated/
56
../registry/
67
node_modules/jpeg-js
78
node_modules/pngjs

0 commit comments

Comments
 (0)