|
| 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; |
0 commit comments