mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(debug): add basic recording helper infra (#2533)
This commit is contained in:
parent
5e97acde0c
commit
e287f19493
@ -32,6 +32,7 @@ import { EventEmitter } from 'events';
|
|||||||
import { FileChooser } from './fileChooser';
|
import { FileChooser } from './fileChooser';
|
||||||
import { logError, InnerLogger } from './logger';
|
import { logError, InnerLogger } from './logger';
|
||||||
import { ProgressController } from './progress';
|
import { ProgressController } from './progress';
|
||||||
|
import { Recorder } from './recorder/recorder';
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
@ -503,6 +504,10 @@ export class Page extends EventEmitter {
|
|||||||
return this.mainFrame().uncheck(selector, options);
|
return this.mainFrame().uncheck(selector, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _startRecordingUser() {
|
||||||
|
new Recorder(this).start();
|
||||||
|
}
|
||||||
|
|
||||||
async waitForTimeout(timeout: number) {
|
async waitForTimeout(timeout: number) {
|
||||||
await this.mainFrame().waitForTimeout(timeout);
|
await this.mainFrame().waitForTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
106
src/recorder/actions.ts
Normal file
106
src/recorder/actions.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ActionName =
|
||||||
|
'goto' |
|
||||||
|
'fill' |
|
||||||
|
'press' |
|
||||||
|
'select';
|
||||||
|
|
||||||
|
export type ClickAction = {
|
||||||
|
name: 'click',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
button: 'left' | 'middle' | 'right',
|
||||||
|
modifiers: number,
|
||||||
|
clickCount: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckAction = {
|
||||||
|
name: 'check',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UncheckAction = {
|
||||||
|
name: 'uncheck',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FillAction = {
|
||||||
|
name: 'fill',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
text: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigateAction = {
|
||||||
|
name: 'navigate',
|
||||||
|
signals?: Signal[],
|
||||||
|
url: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PressAction = {
|
||||||
|
name: 'press',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
key: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectAction = {
|
||||||
|
name: 'select',
|
||||||
|
signals?: Signal[],
|
||||||
|
selector: string,
|
||||||
|
options: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action = ClickAction | CheckAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction;
|
||||||
|
|
||||||
|
// Signals.
|
||||||
|
|
||||||
|
export type NavigationSignal = {
|
||||||
|
name: 'navigation',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Signal = NavigationSignal;
|
||||||
|
|
||||||
|
export function actionTitle(action: Action): string {
|
||||||
|
switch (action.name) {
|
||||||
|
case 'check':
|
||||||
|
return 'Check';
|
||||||
|
case 'uncheck':
|
||||||
|
return 'Uncheck';
|
||||||
|
case 'click': {
|
||||||
|
if (action.clickCount === 1)
|
||||||
|
return 'Click';
|
||||||
|
if (action.clickCount === 2)
|
||||||
|
return 'Double click';
|
||||||
|
if (action.clickCount === 3)
|
||||||
|
return 'Triple click';
|
||||||
|
return `${action.clickCount}× click`;
|
||||||
|
}
|
||||||
|
case 'fill':
|
||||||
|
return 'Fill';
|
||||||
|
case 'navigate':
|
||||||
|
return 'Navigate';
|
||||||
|
case 'press':
|
||||||
|
return 'Press';
|
||||||
|
case 'select':
|
||||||
|
return 'Select';
|
||||||
|
}
|
||||||
|
}
|
76
src/recorder/formatter.ts
Normal file
76
src/recorder/formatter.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Formatter {
|
||||||
|
private _baseIndent: string;
|
||||||
|
private _lines: string[] = [];
|
||||||
|
|
||||||
|
constructor(indent: number = 2) {
|
||||||
|
this._baseIndent = [...Array(indent + 1)].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
prepend(text: string) {
|
||||||
|
this._lines = text.trim().split('\n').map(line => line.trim()).concat(this._lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(text: string) {
|
||||||
|
this._lines.push(...text.trim().split('\n').map(line => line.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
newLine() {
|
||||||
|
this._lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
format(): string {
|
||||||
|
let spaces = '';
|
||||||
|
let previousLine = '';
|
||||||
|
return this._lines.map((line: string) => {
|
||||||
|
if (line === '')
|
||||||
|
return line;
|
||||||
|
if (line.startsWith('}') || line.startsWith(']'))
|
||||||
|
spaces = spaces.substring(this._baseIndent.length);
|
||||||
|
|
||||||
|
const extraSpaces = /^(for|while|if).*\(.*\)$/.test(previousLine) ? this._baseIndent : '';
|
||||||
|
previousLine = line;
|
||||||
|
|
||||||
|
line = spaces + extraSpaces + line;
|
||||||
|
if (line.endsWith('{') || line.endsWith('['))
|
||||||
|
spaces += this._baseIndent;
|
||||||
|
return line;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StringFormatter = (s: string) => string;
|
||||||
|
|
||||||
|
export const formatColors: { cst: StringFormatter; kwd: StringFormatter; fnc: StringFormatter; prp: StringFormatter, str: StringFormatter; cmt: StringFormatter } = {
|
||||||
|
cst: text => `\u001b[38;5;72m${text}\x1b[0m`,
|
||||||
|
kwd: text => `\u001b[38;5;39m${text}\x1b[0m`,
|
||||||
|
fnc: text => `\u001b[38;5;223m${text}\x1b[0m`,
|
||||||
|
prp: text => `\u001b[38;5;159m${text}\x1b[0m`,
|
||||||
|
str: text => `\u001b[38;5;130m${quote(text)}\x1b[0m`,
|
||||||
|
cmt: text => `// \u001b[38;5;23m${text}\x1b[0m`
|
||||||
|
};
|
||||||
|
|
||||||
|
function quote(text: string, char: string = '\'') {
|
||||||
|
if (char === '\'')
|
||||||
|
return char + text.replace(/[']/g, '\\\'').replace(/\\/g, '\\\\') + char;
|
||||||
|
if (char === '"')
|
||||||
|
return char + text.replace(/["]/g, '\\"').replace(/\\/g, '\\\\') + char;
|
||||||
|
if (char === '`')
|
||||||
|
return char + text.replace(/[`]/g, '\\`').replace(/\\/g, '\\\\') + char;
|
||||||
|
throw new Error('Invalid escape char');
|
||||||
|
}
|
149
src/recorder/recorder.ts
Normal file
149
src/recorder/recorder.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as frames from '../frames';
|
||||||
|
import { Page } from '../page';
|
||||||
|
import { Script } from './script';
|
||||||
|
import { Events } from '../events';
|
||||||
|
import * as actions from './actions';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
recordPlaywrightAction: (action: actions.Action) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Recorder {
|
||||||
|
private _page: Page;
|
||||||
|
private _script = new Script();
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this._page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._script.addAction({
|
||||||
|
name: 'navigate',
|
||||||
|
url: this._page.url()
|
||||||
|
});
|
||||||
|
this._printScript();
|
||||||
|
|
||||||
|
this._page.exposeBinding('recordPlaywrightAction', (source, action: actions.Action) => {
|
||||||
|
this._script.addAction(action);
|
||||||
|
this._printScript();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._page.on(Events.Page.FrameNavigated, (frame: frames.Frame) => {
|
||||||
|
if (frame.parentFrame())
|
||||||
|
return;
|
||||||
|
const action = this._script.lastAction();
|
||||||
|
if (action) {
|
||||||
|
action.signals = action.signals || [];
|
||||||
|
action.signals.push({ name: 'navigation', url: frame.url() });
|
||||||
|
}
|
||||||
|
this._printScript();
|
||||||
|
});
|
||||||
|
|
||||||
|
const injectedScript = () => {
|
||||||
|
if (document.readyState === 'complete')
|
||||||
|
addListeners();
|
||||||
|
else
|
||||||
|
document.addEventListener('load', addListeners);
|
||||||
|
|
||||||
|
function addListeners() {
|
||||||
|
document.addEventListener('click', (event: MouseEvent) => {
|
||||||
|
const selector = buildSelector(event.target as Node);
|
||||||
|
if ((event.target as Element).nodeName === 'SELECT')
|
||||||
|
return;
|
||||||
|
window.recordPlaywrightAction({
|
||||||
|
name: 'click',
|
||||||
|
selector,
|
||||||
|
button: buttonForEvent(event),
|
||||||
|
modifiers: modifiersForEvent(event),
|
||||||
|
clickCount: event.detail
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('input', (event: Event) => {
|
||||||
|
const selector = buildSelector(event.target as Node);
|
||||||
|
if ((event.target as Element).nodeName === 'INPUT') {
|
||||||
|
const inputElement = event.target as HTMLInputElement;
|
||||||
|
if ((inputElement.type || '').toLowerCase() === 'checkbox') {
|
||||||
|
window.recordPlaywrightAction({
|
||||||
|
name: inputElement.checked ? 'check' : 'uncheck',
|
||||||
|
selector,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.recordPlaywrightAction({
|
||||||
|
name: 'fill',
|
||||||
|
selector,
|
||||||
|
text: (event.target! as HTMLInputElement).value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((event.target as Element).nodeName === 'SELECT') {
|
||||||
|
const selectElement = event.target as HTMLSelectElement;
|
||||||
|
window.recordPlaywrightAction({
|
||||||
|
name: 'select',
|
||||||
|
selector,
|
||||||
|
options: [...selectElement.selectedOptions].map(option => option.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Tab' && event.key !== 'Enter' && event.key !== 'Escape')
|
||||||
|
return;
|
||||||
|
const selector = buildSelector(event.target as Node);
|
||||||
|
window.recordPlaywrightAction({
|
||||||
|
name: 'press',
|
||||||
|
selector,
|
||||||
|
key: event.key,
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelector(node: Node): string {
|
||||||
|
const element = node as Element;
|
||||||
|
for (const attribute of ['data-testid', 'aria-label', 'id', 'data-test-id', 'data-test']) {
|
||||||
|
if (element.hasAttribute(attribute))
|
||||||
|
return `[${attribute}=${element.getAttribute(attribute)}]`;
|
||||||
|
}
|
||||||
|
if (element.nodeName === 'INPUT')
|
||||||
|
return `[input name=${element.getAttribute('name')}]`;
|
||||||
|
return `text="${element.textContent}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifiersForEvent(event: MouseEvent | KeyboardEvent): number {
|
||||||
|
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonForEvent(event: MouseEvent): 'left' | 'middle' | 'right' {
|
||||||
|
switch (event.which) {
|
||||||
|
case 1: return 'left';
|
||||||
|
case 2: return 'middle';
|
||||||
|
case 3: return 'right';
|
||||||
|
}
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._page.addInitScript(injectedScript);
|
||||||
|
this._page.evaluate(injectedScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
_printScript() {
|
||||||
|
console.log('\x1Bc'); // eslint-disable-line no-console
|
||||||
|
console.log(this._script.generate('chromium')); // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
}
|
151
src/recorder/script.ts
Normal file
151
src/recorder/script.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as dom from '../dom';
|
||||||
|
import { Formatter, formatColors } from './formatter';
|
||||||
|
import { Action, NavigationSignal, actionTitle } from './actions';
|
||||||
|
|
||||||
|
export class Script {
|
||||||
|
private _actions: Action[] = [];
|
||||||
|
|
||||||
|
addAction(action: Action) {
|
||||||
|
this._actions.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAction(): Action | undefined {
|
||||||
|
return this._actions[this._actions.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _compact(): Action[] {
|
||||||
|
const result: Action[] = [];
|
||||||
|
let lastAction: Action | undefined;
|
||||||
|
for (const action of this._actions) {
|
||||||
|
if (lastAction && action.name === 'fill' && lastAction.name === 'fill') {
|
||||||
|
if (action.selector === lastAction.selector)
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
if (lastAction && action.name === 'click' && lastAction.name === 'click') {
|
||||||
|
if (action.selector === lastAction.selector && action.clickCount > lastAction.clickCount)
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
for (const name of ['check', 'uncheck']) {
|
||||||
|
if (lastAction && action.name === name && lastAction.name === 'click') {
|
||||||
|
if ((action as any).selector === (lastAction as any).selector)
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastAction = action;
|
||||||
|
result.push(action);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(browserType: string) {
|
||||||
|
const formatter = new Formatter();
|
||||||
|
const { cst, cmt, fnc, kwd, prp, str } = formatColors;
|
||||||
|
formatter.add(`
|
||||||
|
${kwd('const')} { ${cst('chromium')}. ${cst('firefox')}, ${cst('webkit')} } = ${fnc('require')}(${str('playwright')});
|
||||||
|
|
||||||
|
(${kwd('async')}() => {
|
||||||
|
${kwd('const')} ${cst('browser')} = ${kwd('await')} ${cst(`${browserType}`)}.${fnc('launch')}();
|
||||||
|
${kwd('const')} ${cst('page')} = ${kwd('await')} ${cst('browser')}.${fnc('newPage')}();
|
||||||
|
`);
|
||||||
|
for (const action of this._compact()) {
|
||||||
|
formatter.newLine();
|
||||||
|
formatter.add(cmt(actionTitle(action)));
|
||||||
|
let navigationSignal: NavigationSignal | undefined;
|
||||||
|
if (action.name !== 'navigate' && action.signals && action.signals.length)
|
||||||
|
navigationSignal = action.signals[action.signals.length - 1];
|
||||||
|
if (navigationSignal) {
|
||||||
|
formatter.add(`${kwd('await')} ${cst('Promise')}.${fnc('all')}([
|
||||||
|
${cst('page')}.${fnc('waitForNavigation')}({ ${prp('url')}: ${str(navigationSignal.url)} }),`);
|
||||||
|
}
|
||||||
|
const prefix = navigationSignal ? '' : kwd('await') + ' ';
|
||||||
|
const suffix = navigationSignal ? '' : ';';
|
||||||
|
if (action.name === 'click') {
|
||||||
|
let method = 'click';
|
||||||
|
if (action.clickCount === 2)
|
||||||
|
method = 'dblclick';
|
||||||
|
const modifiers = toModifiers(action.modifiers);
|
||||||
|
const options: dom.ClickOptions = {};
|
||||||
|
if (action.button !== 'left')
|
||||||
|
options.button = action.button;
|
||||||
|
if (modifiers.length)
|
||||||
|
options.modifiers = modifiers;
|
||||||
|
if (action.clickCount > 2)
|
||||||
|
options.clickCount = action.clickCount;
|
||||||
|
const optionsString = formatOptions(options);
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc(method)}(${str(action.selector)}${optionsString})${suffix}`);
|
||||||
|
}
|
||||||
|
if (action.name === 'check')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('check')}(${str(action.selector)})${suffix}`);
|
||||||
|
if (action.name === 'uncheck')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('uncheck')}(${str(action.selector)})${suffix}`);
|
||||||
|
if (action.name === 'fill')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('fill')}(${str(action.selector)}, ${str(action.text)})${suffix}`);
|
||||||
|
if (action.name === 'press')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('press')}(${str(action.selector)}, ${str(action.key)})${suffix}`);
|
||||||
|
if (action.name === 'navigate')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('goto')}(${str(action.url)})${suffix}`);
|
||||||
|
if (action.name === 'select')
|
||||||
|
formatter.add(`${prefix}${cst('page')}.${fnc('select')}(${str(action.selector)}, ${formatObject(action.options.length > 1 ? action.options : action.options[0])})${suffix}`);
|
||||||
|
if (navigationSignal)
|
||||||
|
formatter.add(`]);`);
|
||||||
|
}
|
||||||
|
formatter.add(`
|
||||||
|
})();
|
||||||
|
`);
|
||||||
|
return formatter.format();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOptions(value: any): string {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (!keys.length)
|
||||||
|
return '';
|
||||||
|
return ', ' + formatObject(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatObject(value: any): string {
|
||||||
|
const { prp, str } = formatColors;
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return str(value);
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return `[${value.map(o => formatObject(o)).join(', ')}]`;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (!keys.length)
|
||||||
|
return '{}';
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const key of keys)
|
||||||
|
tokens.push(`${prp(key)}: ${formatObject(value[key])}`);
|
||||||
|
return `{ ${tokens.join(', ')} }`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toModifiers(modifiers: number): ('Alt' | 'Control' | 'Meta' | 'Shift')[] {
|
||||||
|
const result: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = [];
|
||||||
|
if (modifiers & 1)
|
||||||
|
result.push('Alt');
|
||||||
|
if (modifiers & 2)
|
||||||
|
result.push('Control');
|
||||||
|
if (modifiers & 4)
|
||||||
|
result.push('Meta');
|
||||||
|
if (modifiers & 8)
|
||||||
|
result.push('Shift');
|
||||||
|
return result;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user