mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-09 21:28:43 +08:00
Bug: 1134103 Change-Id: Iadd1b4a89d1b40fe48cde1ecf536dcd5e9fcb216 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2675672 Commit-Queue: Andres Olivares <andoli@chromium.org> Reviewed-by: Tim van der Lippe <tvanderlippe@chromium.org>
692 lines
18 KiB
JavaScript
692 lines
18 KiB
JavaScript
/*
|
|
* Copyright (C) 2009 Google Inc. All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without
|
|
* modification, are permitted provided that the following conditions are
|
|
* met:
|
|
*
|
|
* * Redistributions of source code must retain the above copyright
|
|
* notice, this list of conditions and the following disclaimer.
|
|
* * Redistributions in binary form must reproduce the above
|
|
* copyright notice, this list of conditions and the following disclaimer
|
|
* in the documentation and/or other materials provided with the
|
|
* distribution.
|
|
* * Neither the name of Google Inc. nor the names of its
|
|
* contributors may be used to endorse or promote products derived from
|
|
* this software without specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
import * as Common from '../common/common.js'; // eslint-disable-line no-unused-vars
|
|
import * as Host from '../host/host.js';
|
|
import * as Root from '../root/root.js';
|
|
|
|
import {ActionRegistry} from './ActionRegistry.js';
|
|
import {ShortcutRegistry} from './ShortcutRegistry.js';
|
|
import {SoftContextMenu, SoftContextMenuDescriptor} from './SoftContextMenu.js'; // eslint-disable-line no-unused-vars
|
|
import {deepElementFromEvent} from './UIUtils.js';
|
|
|
|
export class Item {
|
|
/**
|
|
* @param {?ContextMenu} contextMenu
|
|
* @param {string} type
|
|
* @param {string=} label
|
|
* @param {boolean=} disabled
|
|
* @param {boolean=} checked
|
|
*/
|
|
constructor(contextMenu, type, label, disabled, checked) {
|
|
this._type = type;
|
|
this._label = label;
|
|
this._disabled = disabled;
|
|
this._checked = checked;
|
|
this._contextMenu = contextMenu;
|
|
this._id = undefined;
|
|
if (type === 'item' || type === 'checkbox') {
|
|
this._id = contextMenu ? contextMenu._nextId() : 0;
|
|
}
|
|
|
|
/** @type {*} */
|
|
this._customElement;
|
|
}
|
|
|
|
/**
|
|
* @return {number}
|
|
*/
|
|
id() {
|
|
if (this._id === undefined) {
|
|
throw new Error('Tried to access a ContextMenu Item ID but none was set.');
|
|
}
|
|
return this._id;
|
|
}
|
|
|
|
/**
|
|
* @return {string}
|
|
*/
|
|
type() {
|
|
return this._type;
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
isEnabled() {
|
|
return !this._disabled;
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} enabled
|
|
*/
|
|
setEnabled(enabled) {
|
|
this._disabled = !enabled;
|
|
}
|
|
|
|
/**
|
|
* @return {!Host.InspectorFrontendHostAPI.ContextMenuDescriptor|!SoftContextMenuDescriptor}
|
|
*/
|
|
_buildDescriptor() {
|
|
switch (this._type) {
|
|
case 'item': {
|
|
const result = {
|
|
type: 'item',
|
|
id: this._id,
|
|
label: this._label,
|
|
enabled: !this._disabled,
|
|
checked: undefined,
|
|
subItems: undefined
|
|
};
|
|
if (this._customElement) {
|
|
const resultAsSoftContextMenuItem = /** @type {!SoftContextMenuDescriptor} */ (result);
|
|
resultAsSoftContextMenuItem.element = /** @type {!Element} */ (this._customElement);
|
|
}
|
|
if (this._shortcut) {
|
|
const resultAsSoftContextMenuItem = /** @type {!SoftContextMenuDescriptor} */ (result);
|
|
resultAsSoftContextMenuItem.shortcut = this._shortcut;
|
|
}
|
|
return result;
|
|
}
|
|
case 'separator': {
|
|
return {
|
|
type: 'separator',
|
|
id: undefined,
|
|
label: undefined,
|
|
enabled: undefined,
|
|
checked: undefined,
|
|
subItems: undefined
|
|
};
|
|
}
|
|
case 'checkbox': {
|
|
return {
|
|
type: 'checkbox',
|
|
id: this._id,
|
|
label: this._label,
|
|
checked: Boolean(this._checked),
|
|
enabled: !this._disabled,
|
|
subItems: undefined
|
|
};
|
|
}
|
|
}
|
|
throw new Error('Invalid item type:' + this._type);
|
|
}
|
|
|
|
/**
|
|
* @param {string} shortcut
|
|
*/
|
|
setShortcut(shortcut) {
|
|
this._shortcut = shortcut;
|
|
}
|
|
}
|
|
|
|
|
|
export class Section {
|
|
/**
|
|
* @param {?ContextMenu} contextMenu
|
|
*/
|
|
constructor(contextMenu) {
|
|
this._contextMenu = contextMenu;
|
|
/** @type {!Array<!Item>} */
|
|
this._items = [];
|
|
}
|
|
|
|
/**
|
|
* @param {string} label
|
|
* @param {function():*} handler
|
|
* @param {boolean=} disabled
|
|
* @return {!Item}
|
|
*/
|
|
appendItem(label, handler, disabled) {
|
|
const item = new Item(this._contextMenu, 'item', label, disabled);
|
|
this._items.push(item);
|
|
if (this._contextMenu) {
|
|
this._contextMenu._setHandler(item.id(), handler);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* @param {!Element} element
|
|
* @return {!Item}
|
|
*/
|
|
appendCustomItem(element) {
|
|
const item = new Item(this._contextMenu, 'item', '<custom>');
|
|
item._customElement = element;
|
|
this._items.push(item);
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* @return {!Item}
|
|
*/
|
|
appendSeparator() {
|
|
const item = new Item(this._contextMenu, 'separator');
|
|
this._items.push(item);
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* @param {string} actionId
|
|
* @param {string=} label
|
|
* @param {boolean=} optional
|
|
*/
|
|
appendAction(actionId, label, optional) {
|
|
const action = ActionRegistry.instance().action(actionId);
|
|
if (!action) {
|
|
if (!optional) {
|
|
console.error(`Action ${actionId} was not defined`);
|
|
}
|
|
return;
|
|
}
|
|
if (!label) {
|
|
label = action.title();
|
|
}
|
|
const result = this.appendItem(label, action.execute.bind(action));
|
|
const shortcut = ShortcutRegistry.instance().shortcutTitleForAction(actionId);
|
|
if (shortcut) {
|
|
result.setShortcut(shortcut);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} label
|
|
* @param {boolean=} disabled
|
|
* @return {!SubMenu}
|
|
*/
|
|
appendSubMenuItem(label, disabled) {
|
|
const item = new SubMenu(this._contextMenu, label, disabled);
|
|
item._init();
|
|
this._items.push(item);
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* @param {string} label
|
|
* @param {function():*} handler
|
|
* @param {boolean=} checked
|
|
* @param {boolean=} disabled
|
|
* @return {!Item}
|
|
*/
|
|
appendCheckboxItem(label, handler, checked, disabled) {
|
|
const item = new Item(this._contextMenu, 'checkbox', label, disabled, checked);
|
|
this._items.push(item);
|
|
if (this._contextMenu) {
|
|
this._contextMenu._setHandler(item.id(), handler);
|
|
}
|
|
return item;
|
|
}
|
|
}
|
|
|
|
|
|
export class SubMenu extends Item {
|
|
/**
|
|
* @param {?ContextMenu} contextMenu
|
|
* @param {string=} label
|
|
* @param {boolean=} disabled
|
|
*/
|
|
constructor(contextMenu, label, disabled) {
|
|
super(contextMenu, 'subMenu', label, disabled);
|
|
/** @type {!Map<string, !Section>} */
|
|
this._sections = new Map();
|
|
/** @type {!Array<!Section>} */
|
|
this._sectionList = [];
|
|
}
|
|
|
|
_init() {
|
|
_groupWeights.forEach(name => this.section(name));
|
|
}
|
|
|
|
/**
|
|
* @param {string=} name
|
|
* @return {!Section}
|
|
*/
|
|
section(name) {
|
|
let section = name ? this._sections.get(name) : null;
|
|
if (!section) {
|
|
section = new Section(this._contextMenu);
|
|
if (name) {
|
|
this._sections.set(name, section);
|
|
this._sectionList.push(section);
|
|
} else {
|
|
this._sectionList.splice(ContextMenu._groupWeights.indexOf('default'), 0, section);
|
|
}
|
|
}
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
headerSection() {
|
|
return this.section('header');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
newSection() {
|
|
return this.section('new');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
revealSection() {
|
|
return this.section('reveal');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
clipboardSection() {
|
|
return this.section('clipboard');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
editSection() {
|
|
return this.section('edit');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
debugSection() {
|
|
return this.section('debug');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
viewSection() {
|
|
return this.section('view');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
defaultSection() {
|
|
return this.section('default');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
saveSection() {
|
|
return this.section('save');
|
|
}
|
|
|
|
/**
|
|
* @return {!Section}
|
|
*/
|
|
footerSection() {
|
|
return this.section('footer');
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @return {!Host.InspectorFrontendHostAPI.ContextMenuDescriptor|!SoftContextMenuDescriptor}
|
|
*/
|
|
_buildDescriptor() {
|
|
/** @type {!Host.InspectorFrontendHostAPI.ContextMenuDescriptor|!SoftContextMenuDescriptor} */
|
|
const result = {
|
|
type: 'subMenu',
|
|
label: this._label,
|
|
enabled: !this._disabled,
|
|
subItems: [],
|
|
id: undefined,
|
|
checked: undefined
|
|
};
|
|
|
|
const nonEmptySections = this._sectionList.filter(section => Boolean(section._items.length));
|
|
for (const section of nonEmptySections) {
|
|
for (const item of section._items) {
|
|
if (!result.subItems) {
|
|
result.subItems = [];
|
|
}
|
|
result.subItems.push(item._buildDescriptor());
|
|
}
|
|
if (section !== nonEmptySections[nonEmptySections.length - 1]) {
|
|
if (!result.subItems) {
|
|
result.subItems = [];
|
|
}
|
|
result.subItems.push({
|
|
type: 'separator',
|
|
id: undefined,
|
|
subItems: undefined,
|
|
checked: undefined,
|
|
enabled: undefined,
|
|
label: undefined
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} location
|
|
*/
|
|
appendItemsAtLocation(location) {
|
|
for (const extension of Root.Runtime.Runtime.instance().extensions('context-menu-item')) {
|
|
const itemLocation = extension.descriptor()['location'] || '';
|
|
if (!itemLocation.startsWith(location + '/')) {
|
|
continue;
|
|
}
|
|
|
|
const section = itemLocation.substr(location.length + 1);
|
|
if (!section || section.includes('/')) {
|
|
continue;
|
|
}
|
|
|
|
const actionId = extension.descriptor().actionId;
|
|
if (actionId) {
|
|
this.section(section).appendAction(actionId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item._uniqueSectionName = 0;
|
|
|
|
export class ContextMenu extends SubMenu {
|
|
/**
|
|
* @param {!Event} event
|
|
* @param {boolean=} useSoftMenu
|
|
* @param {number=} x
|
|
* @param {number=} y
|
|
*/
|
|
constructor(event, useSoftMenu, x, y) {
|
|
super(null);
|
|
const mouseEvent = /** @type {!MouseEvent} */ (event);
|
|
this._contextMenu = this;
|
|
super._init();
|
|
this._defaultSection = this.defaultSection();
|
|
/** @type {!Array.<!Promise.<!Array.<!Provider>>>} */
|
|
this._pendingPromises = [];
|
|
/** @type {!Array<!Object>} */
|
|
this._pendingTargets = [];
|
|
/** @type {!MouseEvent} */
|
|
this._event = mouseEvent;
|
|
this._useSoftMenu = Boolean(useSoftMenu);
|
|
this._x = x === undefined ? mouseEvent.x : x;
|
|
this._y = y === undefined ? mouseEvent.y : y;
|
|
/** @type {!Map<number, function():*>} */
|
|
this._handlers = new Map();
|
|
this._id = 0;
|
|
|
|
const target = deepElementFromEvent(event);
|
|
if (target) {
|
|
this.appendApplicableItems(/** @type {!Object} */ (target));
|
|
}
|
|
}
|
|
|
|
static initialize() {
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener(
|
|
Host.InspectorFrontendHostAPI.Events.SetUseSoftMenu, setUseSoftMenu);
|
|
/**
|
|
* @param {!Common.EventTarget.EventTargetEvent} event
|
|
*/
|
|
function setUseSoftMenu(event) {
|
|
ContextMenu._useSoftMenu = /** @type {boolean} */ (event.data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!Document} doc
|
|
*/
|
|
static installHandler(doc) {
|
|
doc.body.addEventListener('contextmenu', handler, false);
|
|
|
|
/**
|
|
* @param {!Event} event
|
|
*/
|
|
function handler(event) {
|
|
const contextMenu = new ContextMenu(event);
|
|
contextMenu.show();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return {number}
|
|
*/
|
|
_nextId() {
|
|
return this._id++;
|
|
}
|
|
|
|
async show() {
|
|
ContextMenu._pendingMenu = this;
|
|
this._event.consume(true);
|
|
/** @type {!Array<!Array<!Provider>>} */
|
|
const loadedProviders = await Promise.all(this._pendingPromises);
|
|
|
|
// After loading all providers, the contextmenu might be hidden again, so bail out.
|
|
if (ContextMenu._pendingMenu !== this) {
|
|
return;
|
|
}
|
|
ContextMenu._pendingMenu = null;
|
|
|
|
for (let i = 0; i < loadedProviders.length; ++i) {
|
|
const providers = loadedProviders[i];
|
|
const target = this._pendingTargets[i];
|
|
|
|
for (const provider of providers) {
|
|
provider.appendApplicableItems(this._event, this, target);
|
|
}
|
|
}
|
|
|
|
this._pendingPromises = [];
|
|
this._pendingTargets = [];
|
|
|
|
this._innerShow();
|
|
}
|
|
|
|
discard() {
|
|
if (this._softMenu) {
|
|
this._softMenu.discard();
|
|
}
|
|
}
|
|
|
|
_innerShow() {
|
|
const menuObject = this._buildMenuDescriptors();
|
|
const eventTarget = this._event.target;
|
|
if (!eventTarget) {
|
|
return;
|
|
}
|
|
const ownerDocument = /** @type {!HTMLElement} */ (eventTarget).ownerDocument;
|
|
if (this._useSoftMenu || ContextMenu._useSoftMenu ||
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) {
|
|
this._softMenu = new SoftContextMenu(
|
|
/** @type {!Array<!SoftContextMenuDescriptor>} */ (menuObject), this._itemSelected.bind(this));
|
|
this._softMenu.show(/** @type {!Document} */ (ownerDocument), new AnchorBox(this._x, this._y, 0, 0));
|
|
} else {
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.showContextMenuAtPoint(
|
|
this._x, this._y, menuObject, /** @type {!Document}*/ (ownerDocument));
|
|
|
|
/**
|
|
* @this {ContextMenu}
|
|
*/
|
|
function listenToEvents() {
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener(
|
|
Host.InspectorFrontendHostAPI.Events.ContextMenuCleared, this._menuCleared, this);
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener(
|
|
Host.InspectorFrontendHostAPI.Events.ContextMenuItemSelected, this._onItemSelected, this);
|
|
}
|
|
|
|
// showContextMenuAtPoint call above synchronously issues a clear event for previous context menu (if any),
|
|
// so we skip it before subscribing to the clear event.
|
|
queueMicrotask(listenToEvents.bind(this));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} x
|
|
*/
|
|
setX(x) {
|
|
this._x = x;
|
|
}
|
|
|
|
/**
|
|
* @param {number} y
|
|
*/
|
|
setY(y) {
|
|
this._y = y;
|
|
}
|
|
|
|
/**
|
|
* @param {number} id
|
|
* @param {function():*} handler
|
|
*/
|
|
_setHandler(id, handler) {
|
|
if (handler) {
|
|
this._handlers.set(id, handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return {!Array.<!Host.InspectorFrontendHostAPI.ContextMenuDescriptor|!SoftContextMenuDescriptor>}
|
|
*/
|
|
_buildMenuDescriptors() {
|
|
return /** @type {!Array.<!Host.InspectorFrontendHostAPI.ContextMenuDescriptor|!SoftContextMenuDescriptor>} */ (
|
|
super._buildDescriptor().subItems);
|
|
}
|
|
|
|
/**
|
|
* @param {!Common.EventTarget.EventTargetEvent} event
|
|
*/
|
|
_onItemSelected(event) {
|
|
this._itemSelected(/** @type {number} */ (event.data));
|
|
}
|
|
|
|
/**
|
|
* @param {number} id
|
|
*/
|
|
_itemSelected(id) {
|
|
const handler = this._handlers.get(id);
|
|
if (handler) {
|
|
handler.call(this);
|
|
}
|
|
this._menuCleared();
|
|
}
|
|
|
|
_menuCleared() {
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.removeEventListener(
|
|
Host.InspectorFrontendHostAPI.Events.ContextMenuCleared, this._menuCleared, this);
|
|
Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.removeEventListener(
|
|
Host.InspectorFrontendHostAPI.Events.ContextMenuItemSelected, this._onItemSelected, this);
|
|
}
|
|
|
|
/**
|
|
* @param {!Object} target
|
|
* @return {boolean}
|
|
*/
|
|
containsTarget(target) {
|
|
return this._pendingTargets.indexOf(target) >= 0;
|
|
}
|
|
|
|
/**
|
|
* @param {!Object} target
|
|
*/
|
|
appendApplicableItems(target) {
|
|
this._pendingPromises.push(loadApplicableRegisteredProviders(target));
|
|
this._pendingTargets.push(target);
|
|
}
|
|
}
|
|
|
|
ContextMenu._useSoftMenu = false;
|
|
/** @type {?ContextMenu} */
|
|
ContextMenu._pendingMenu = null;
|
|
|
|
export const _groupWeights =
|
|
['header', 'new', 'reveal', 'edit', 'clipboard', 'debug', 'view', 'default', 'save', 'footer'];
|
|
|
|
/**
|
|
* @interface
|
|
*/
|
|
export class Provider {
|
|
/**
|
|
* @param {!Event} event
|
|
* @param {!ContextMenu} contextMenu
|
|
* @param {!Object} target
|
|
*/
|
|
appendApplicableItems(event, contextMenu, target) {}
|
|
}
|
|
|
|
/** @type {!Array<!ProviderRegistration>} */
|
|
const registeredProviders = [];
|
|
|
|
/**
|
|
* @param {!ProviderRegistration} registration
|
|
*/
|
|
export function registerProvider(registration) {
|
|
registeredProviders.push(registration);
|
|
}
|
|
|
|
/**
|
|
* @param {!Object} target
|
|
* @return {!Promise<Array<Provider>>}
|
|
*/
|
|
async function loadApplicableRegisteredProviders(target) {
|
|
return Promise.all(
|
|
registeredProviders.filter(isProviderApplicableToContextTypes).map(registration => registration.loadProvider()));
|
|
|
|
/**
|
|
* @param {!ProviderRegistration} providerRegistration
|
|
* @return {boolean}
|
|
*/
|
|
function isProviderApplicableToContextTypes(providerRegistration) {
|
|
if (!Root.Runtime.Runtime.isDescriptorEnabled(
|
|
{experiment: providerRegistration.experiment, condition: undefined})) {
|
|
return false;
|
|
}
|
|
if (!providerRegistration.contextTypes) {
|
|
return true;
|
|
}
|
|
for (const contextType of providerRegistration.contextTypes()) {
|
|
if (target instanceof contextType) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {{
|
|
* contextTypes: function(): !Array<?>,
|
|
* loadProvider: function(): !Promise<!Provider>,
|
|
* experiment: (undefined|Root.Runtime.ExperimentName)
|
|
* }} */
|
|
// @ts-ignore typedef
|
|
export let ProviderRegistration;
|
|
|
|
ContextMenu._groupWeights = _groupWeights;
|