Skip to content

Commit cb148e5

Browse files
committed
fix(webview): support input for elements inside cross-origin <iframe>
1 parent 434bb84 commit cb148e5

4 files changed

Lines changed: 86 additions & 76 deletions

File tree

packages/injected/src/webview/webViewInput.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ function markAndDispatch(node: EventTarget, event: Event): boolean {
6666
return node.dispatchEvent(event);
6767
}
6868

69+
function isFrameOwner(element: Element): element is HTMLIFrameElement | HTMLFrameElement {
70+
return element.localName === 'iframe' || element.localName === 'frame';
71+
}
72+
6973
// Legacy WebKit-only KeyboardEvent.keyIdentifier (a DOM Level 3 draft property
7074
// dropped by every other engine). It cannot be supplied via the constructor, so
7175
// compute it from the virtual key code and define it on the event before
@@ -132,17 +136,29 @@ export class WebViewInput {
132136
});
133137
}
134138

135-
// Descend through open shadow roots so synthetic events land on the actual
136-
// element under the pointer rather than on the shadow host.
137-
private _deepElementFromPoint(x: number, y: number): Element | null {
139+
private _hitTest(x: number, y: number): { target: Element | null, iframe: HTMLIFrameElement | HTMLFrameElement | null, x: number, y: number } {
138140
let el = this._document.elementFromPoint(x, y);
139141
while (el && el.shadowRoot) {
140142
const inner = el.shadowRoot.elementFromPoint(x, y);
141143
if (!inner || inner === el)
142144
break;
143145
el = inner;
144146
}
145-
return el;
147+
if (!el || !isFrameOwner(el))
148+
return { target: el, iframe: null, x, y };
149+
const frameRect = el.getBoundingClientRect();
150+
const frameStyle = this._window.getComputedStyle(el);
151+
return {
152+
target: el,
153+
iframe: el,
154+
x: x - frameRect.left - parseFloat(frameStyle.borderLeftWidth) - parseFloat(frameStyle.paddingLeft),
155+
y: y - frameRect.top - parseFloat(frameStyle.borderTopWidth) - parseFloat(frameStyle.paddingTop),
156+
};
157+
}
158+
159+
positionInIFrame(x: number, y: number): { iframe: HTMLIFrameElement | HTMLFrameElement | null, x: number, y: number } {
160+
const hit = this._hitTest(x, y);
161+
return { iframe: hit.iframe, x: hit.x, y: hit.y };
146162
}
147163

148164
// The focused element may live inside one or more shadow roots, where
@@ -154,6 +170,11 @@ export class WebViewInput {
154170
return active;
155171
}
156172

173+
activeIFrame(): HTMLIFrameElement | HTMLFrameElement | null {
174+
const active = this._deepActiveElement();
175+
return active && isFrameOwner(active) ? active : null;
176+
}
177+
157178
private _insertText(target: Element | null, text: string) {
158179
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
159180
const start = target.selectionStart ?? target.value.length;
@@ -252,7 +273,7 @@ export class WebViewInput {
252273
}
253274

254275
mouseMove(params: MouseMoveParams): Promise<void> {
255-
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
276+
const target = this._hitTest(params.x, params.y).target || this._document.documentElement;
256277
const base: MouseEventInit = {
257278
bubbles: true,
258279
cancelable: true,
@@ -291,7 +312,7 @@ export class WebViewInput {
291312
// Resolve the hit target at dispatch time, not enqueue time: a queued move
292313
// ahead of this may reveal an overlay that should receive the press.
293314
return this._postTask(() => {
294-
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
315+
const target = this._hitTest(params.x, params.y).target || this._document.documentElement;
295316
markAndDispatch(target, new MouseEvent(params.type, {
296317
bubbles: true,
297318
cancelable: true,
@@ -313,7 +334,7 @@ export class WebViewInput {
313334

314335
wheel(params: WheelParams): Promise<void> {
315336
return this._postTask(() => {
316-
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
337+
const target = this._hitTest(params.x, params.y).target || this._document.documentElement;
317338
markAndDispatch(target, new WheelEvent('wheel', {
318339
bubbles: true,
319340
cancelable: true,
@@ -335,7 +356,7 @@ export class WebViewInput {
335356
}
336357

337358
tap(params: TapParams): Promise<void> {
338-
const target = this._deepElementFromPoint(params.x, params.y) || this._document.documentElement;
359+
const target = this._hitTest(params.x, params.y).target || this._document.documentElement;
339360
const init: MouseEventInit = {
340361
bubbles: true,
341362
cancelable: true,
@@ -350,7 +371,7 @@ export class WebViewInput {
350371
metaKey: params.metaKey,
351372
};
352373
try {
353-
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 });
374+
const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x + this._window.scrollX, pageY: params.y + this._window.scrollY, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 });
354375
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] })));
355376
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] })));
356377
} catch {

packages/playwright-core/src/server/webkit/webview/wvInput.ts

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import * as input from '../../input';
1919

2020
import type * as types from '../../types';
21-
import type { WVSession } from './wvConnection';
2221
import type { Progress } from '../../progress';
2322

2423
function modifierFlags(modifiers: Set<types.KeyboardModifier>) {
@@ -51,11 +50,13 @@ function toButtonsMask(buttons: Set<types.MouseButton>): number {
5150
return mask;
5251
}
5352

53+
export type DispatchWebViewInput = (progress: Progress, method: string, params: any) => Promise<void>;
54+
5455
export class RawKeyboardImpl implements input.RawKeyboard {
55-
private _session: WVSession | undefined;
56+
private _dispatcher: DispatchWebViewInput;
5657

57-
setSession(session: WVSession) {
58-
this._session = session;
58+
constructor(dispatcher: DispatchWebViewInput) {
59+
this._dispatcher = dispatcher;
5960
}
6061

6162
async keydown(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise<void> {
@@ -65,29 +66,29 @@ export class RawKeyboardImpl implements input.RawKeyboard {
6566
...modifierFlags(modifiers),
6667
...(text ? { text } : {}),
6768
};
68-
await callWebViewInput(progress, this._session, 'keydown', params);
69+
await this._dispatcher(progress, 'keydown', params);
6970
}
7071

7172
async keyup(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription): Promise<void> {
7273
const { code, keyCode, key, location } = description;
7374
const params = { code, key, keyCode, location, ...modifierFlags(modifiers) };
74-
await callWebViewInput(progress, this._session, 'keyup', params);
75+
await this._dispatcher(progress, 'keyup', params);
7576
}
7677

7778
async sendText(progress: Progress, text: string): Promise<void> {
78-
await callWebViewInput(progress, this._session, 'insertText', text);
79+
await this._dispatcher(progress, 'insertText', text);
7980
}
8081
}
8182

8283
export class RawMouseImpl implements input.RawMouse {
83-
private _session: WVSession | undefined;
84+
private _dispatcher: DispatchWebViewInput;
8485

85-
setSession(session: WVSession) {
86-
this._session = session;
86+
constructor(dispatcher: DispatchWebViewInput) {
87+
this._dispatcher = dispatcher;
8788
}
8889

8990
async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
90-
await callWebViewInput(progress, this._session, 'mouseMove', {
91+
await this._dispatcher(progress, 'mouseMove', {
9192
x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers),
9293
});
9394
}
@@ -114,43 +115,24 @@ export class RawMouseImpl implements input.RawMouse {
114115
}
115116

116117
async wheel(progress: Progress, x: number, y: number, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, deltaX: number, deltaY: number): Promise<void> {
117-
await callWebViewInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) });
118+
await this._dispatcher(progress, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) });
118119
}
119120

120121
private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set<types.KeyboardModifier>, clickCount: number) {
121-
await callWebViewInput(progress, this._session, 'mouseEvent', {
122+
await this._dispatcher(progress, 'mouseEvent', {
122123
type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers),
123124
});
124125
}
125126
}
126127

127128
export class RawTouchscreenImpl implements input.RawTouchscreen {
128-
private _session: WVSession | undefined;
129+
private _dispatcher: DispatchWebViewInput;
129130

130-
setSession(session: WVSession) {
131-
this._session = session;
131+
constructor(dispatcher: DispatchWebViewInput) {
132+
this._dispatcher = dispatcher;
132133
}
133134

134135
async tap(progress: Progress, x: number, y: number, modifiers: Set<types.KeyboardModifier>) {
135-
await callWebViewInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) });
136-
}
137-
}
138-
139-
async function callWebViewInput(progress: Progress, session: WVSession | undefined, method: string, arg: any): Promise<void> {
140-
if (!session)
141-
throw new Error('Page is not initialized');
142-
const expression = `window.__pwWebViewInput.${method}(${JSON.stringify(arg)})`;
143-
// Some dispatchers are async — they spread events across event-loop tasks the
144-
// way a real device does. Await the returned promise so the action only
145-
// resolves once every event has been delivered. Stock WebKit's Runtime.evaluate
146-
// has no awaitPromise option, so use the separate Runtime.awaitPromise command.
147-
const { result } = await progress.race(session.send('Runtime.evaluate', { expression, returnByValue: false }));
148-
// `result` is absent if evaluation failed (e.g. the frame navigated away).
149-
// Only promises carry an objectId here — every __pwWebViewInput method returns
150-
// void or a Promise — so this both awaits async dispatch and avoids leaking a
151-
// handle for the synchronous (void) case.
152-
if (result?.className === 'Promise' && result.objectId) {
153-
await progress.race(session.send('Runtime.awaitPromise', { promiseObjectId: result.objectId, returnByValue: true }));
154-
session.sendMayFail('Runtime.releaseObject', { objectId: result.objectId });
136+
await this._dispatcher(progress, 'tap', { x, y, ...modifierFlags(modifiers) });
155137
}
156138
}

packages/playwright-core/src/server/webkit/webview/wvPage.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,10 @@ export class WVPage implements PageDelegate {
7979
constructor(browserContext: WVBrowserContext, outerSession: WVSession, dialogEndpoint?: string) {
8080
this._outerSession = outerSession;
8181
this._dialogEndpoint = dialogEndpoint;
82-
this.rawKeyboard = new RawKeyboardImpl();
83-
this.rawMouse = new RawMouseImpl();
84-
this.rawTouchscreen = new RawTouchscreenImpl();
82+
const dispatchInput = this._dispatchWebViewInput.bind(this);
83+
this.rawKeyboard = new RawKeyboardImpl(dispatchInput);
84+
this.rawMouse = new RawMouseImpl(dispatchInput);
85+
this.rawTouchscreen = new RawTouchscreenImpl(dispatchInput);
8586
this._contextIdToContext = new Map();
8687
this._page = new Page(this, browserContext);
8788
this._workers = new WVWorkers(this._page, outerSession);
@@ -192,9 +193,6 @@ export class WVPage implements PageDelegate {
192193
private _setSession(session: WVSession) {
193194
eventsHelper.removeEventListeners(this._sessionListeners);
194195
this._session = session;
195-
this.rawKeyboard.setSession(session);
196-
this.rawMouse.setSession(session);
197-
this.rawTouchscreen.setSession(session);
198196
this._workers.setSession(session);
199197
this._addSessionListeners();
200198
}
@@ -917,6 +915,40 @@ export class WVPage implements PageDelegate {
917915
async inputActionEpilogue(): Promise<void> {
918916
}
919917

918+
private async _dispatchWebViewInput(progress: Progress, method: string, params: any): Promise<void> {
919+
const positional = typeof params === 'object' && params !== null && 'x' in params;
920+
let frame: frames.Frame = this._page.mainFrame();
921+
let x = positional ? params.x as number : 0;
922+
let y = positional ? params.y as number : 0;
923+
for (;;) {
924+
const context = await progress.race(frame.mainContext());
925+
const position = positional
926+
? await progress.race(context.evaluateHandle(([px, py]) => (globalThis as any).__pwWebViewInput.positionInIFrame(px, py), [x, y]))
927+
: undefined;
928+
try {
929+
const iframe = (position
930+
? await position.getProperty(progress, 'iframe')
931+
: await progress.race(context.evaluateHandle(() => (globalThis as any).__pwWebViewInput.activeIFrame()))) as dom.ElementHandle;
932+
let childFrame: frames.Frame | null;
933+
try {
934+
childFrame = await progress.race(this.getContentFrame(iframe));
935+
} finally {
936+
iframe.dispose();
937+
}
938+
if (!childFrame)
939+
break;
940+
if (position)
941+
({ x, y } = await progress.race(position.evaluate(result => ({ x: result.x, y: result.y }))));
942+
frame = childFrame;
943+
} finally {
944+
position?.dispose();
945+
}
946+
}
947+
const context = await progress.race(frame.mainContext());
948+
// This will automatically await if the result is a promise, so async dispatchers deliver every event before this resolves.
949+
await progress.race(context.evaluate(({ method, params }) => (globalThis as any).__pwWebViewInput[method](params), { method, params: positional ? { ...params, x, y } : params }));
950+
}
951+
920952
async resetForReuse(progress: Progress): Promise<void> {
921953
}
922954

0 commit comments

Comments
 (0)