UNPKG

chrome-devtools-frontend

Version:
1,645 lines (1,465 loc) • 65 kB
/* * Copyright (C) 2011 Google Inc. All rights reserved. * Copyright (C) 2006, 2007, 2008 Apple Inc. All rights reserved. * Copyright (C) 2007 Matt Lilek (pewtermoose@gmail.com). * Copyright (C) 2009 Joseph Pecoraro * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. 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. * 3. Neither the name of Apple Computer, Inc. ("Apple") 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 APPLE AND ITS 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 APPLE OR ITS 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'; import * as DOMExtension from '../dom_extension/dom_extension.js'; import * as Host from '../host/host.js'; import * as Platform from '../platform/platform.js'; import {ls} from '../platform/platform.js'; import * as TextUtils from '../text_utils/text_utils.js'; import * as ThemeSupport from '../theme_support/theme_support.js'; import * as ARIAUtils from './ARIAUtils.js'; import {Dialog} from './Dialog.js'; import {Size} from './Geometry.js'; import {GlassPane, PointerEventsBehavior, SizeBehavior} from './GlassPane.js'; import {Icon} from './Icon.js'; import {KeyboardShortcut} from './KeyboardShortcut.js'; import {Toolbar, ToolbarButton} from './Toolbar.js'; // eslint-disable-line no-unused-vars import {Tooltip} from './Tooltip.js'; import {TreeOutline} from './Treeoutline.js'; // eslint-disable-line no-unused-vars import {appendStyle} from './utils/append-style.js'; import {createShadowRootWithCoreStyles} from './utils/create-shadow-root-with-core-styles.js'; import {focusChanged} from './utils/focus-changed.js'; import {injectCoreStyles} from './utils/inject-core-styles.js'; import {measuredScrollbarWidth} from './utils/measured-scrollbar-width.js'; import {registerCustomElement} from './utils/register-custom-element.js'; export const highlightedSearchResultClassName = 'highlighted-search-result'; export const highlightedCurrentSearchResultClassName = 'current-search-result'; /** * @param {!Element} element * @param {?function(!MouseEvent): boolean} elementDragStart * @param {function(!MouseEvent):void} elementDrag * @param {?function(!MouseEvent):void} elementDragEnd * @param {?string} cursor * @param {?string=} hoverCursor * @param {number=} startDelay */ export function installDragHandle( element, elementDragStart, elementDrag, elementDragEnd, cursor, hoverCursor, startDelay) { /** * @param {!Event} event */ function onMouseDown(event) { const dragHandler = new DragHandler(); const dragStart = () => dragHandler.elementDragStart(element, elementDragStart, elementDrag, elementDragEnd, cursor, event); if (startDelay) { startTimer = window.setTimeout(dragStart, startDelay); } else { dragStart(); } } function onMouseUp() { if (startTimer) { window.clearTimeout(startTimer); } startTimer = null; } /** @type {?number} */ let startTimer; element.addEventListener('mousedown', onMouseDown, false); if (startDelay) { element.addEventListener('mouseup', onMouseUp, false); } if (hoverCursor !== null) { /** @type {!HTMLElement} */ (element).style.cursor = hoverCursor || cursor || ''; } } /** * @param {!Element} targetElement * @param {?function(!MouseEvent):boolean} elementDragStart * @param {function(!MouseEvent):void} elementDrag * @param {?function(!MouseEvent):void} elementDragEnd * @param {?string} cursor * @param {!Event} event */ export function elementDragStart(targetElement, elementDragStart, elementDrag, elementDragEnd, cursor, event) { const dragHandler = new DragHandler(); dragHandler.elementDragStart(targetElement, elementDragStart, elementDrag, elementDragEnd, cursor, event); } class DragHandler { constructor() { this._elementDragMove = this._elementDragMove.bind(this); this._elementDragEnd = this._elementDragEnd.bind(this); this._mouseOutWhileDragging = this._mouseOutWhileDragging.bind(this); } _createGlassPane() { this._glassPaneInUse = true; if (!DragHandler._glassPaneUsageCount++) { DragHandler._glassPane = new GlassPane(); DragHandler._glassPane.setPointerEventsBehavior(PointerEventsBehavior.BlockedByGlassPane); if (DragHandler._documentForMouseOut) { DragHandler._glassPane.show(DragHandler._documentForMouseOut); } } } _disposeGlassPane() { if (!this._glassPaneInUse) { return; } this._glassPaneInUse = false; if (--DragHandler._glassPaneUsageCount) { return; } if (DragHandler._glassPane) { DragHandler._glassPane.hide(); DragHandler._glassPane = null; } DragHandler._documentForMouseOut = null; } /** * @param {!Element} targetElement * @param {?function(!MouseEvent):boolean} elementDragStart * @param {function(!MouseEvent):void|boolean} elementDrag * @param {?function(!MouseEvent):void} elementDragEnd * @param {?string} cursor * @param {!Event} ev */ elementDragStart(targetElement, elementDragStart, elementDrag, elementDragEnd, cursor, ev) { const event = /** @type {!MouseEvent} */ (ev); // Only drag upon left button. Right will likely cause a context menu. So will ctrl-click on mac. if (event.button || (Host.Platform.isMac() && event.ctrlKey)) { return; } if (this._elementDraggingEventListener) { return; } if (elementDragStart && !elementDragStart(/** @type {!MouseEvent} */ (event))) { return; } const targetDocument = /** @type {!Document} */ (event.target instanceof Node && event.target.ownerDocument); this._elementDraggingEventListener = elementDrag; this._elementEndDraggingEventListener = elementDragEnd; console.assert( (DragHandler._documentForMouseOut || targetDocument) === targetDocument, 'Dragging on multiple documents.'); DragHandler._documentForMouseOut = targetDocument; this._dragEventsTargetDocument = targetDocument; try { if (targetDocument.defaultView) { this._dragEventsTargetDocumentTop = targetDocument.defaultView.top.document; } } catch (e) { this._dragEventsTargetDocumentTop = this._dragEventsTargetDocument; } targetDocument.addEventListener('mousemove', e => this._elementDragMove(/** @type {!MouseEvent} */ (e)), true); targetDocument.addEventListener('mouseup', this._elementDragEnd, true); targetDocument.addEventListener('mouseout', this._mouseOutWhileDragging, true); if (this._dragEventsTargetDocumentTop && targetDocument !== this._dragEventsTargetDocumentTop) { this._dragEventsTargetDocumentTop.addEventListener('mouseup', this._elementDragEnd, true); } const targetHtmlElement = /** @type {!HTMLElement} */ (targetElement); if (typeof cursor === 'string') { this._restoreCursorAfterDrag = restoreCursor.bind(this, targetHtmlElement.style.cursor); targetHtmlElement.style.cursor = cursor; targetDocument.body.style.cursor = cursor; } /** * @param {string} oldCursor * @this {DragHandler} */ function restoreCursor(oldCursor) { targetDocument.body.style.removeProperty('cursor'); targetHtmlElement.style.cursor = oldCursor; this._restoreCursorAfterDrag = undefined; } event.preventDefault(); } _mouseOutWhileDragging() { this._unregisterMouseOutWhileDragging(); this._createGlassPane(); } _unregisterMouseOutWhileDragging() { if (!DragHandler._documentForMouseOut) { return; } DragHandler._documentForMouseOut.removeEventListener('mouseout', this._mouseOutWhileDragging, true); } _unregisterDragEvents() { if (!this._dragEventsTargetDocument) { return; } this._dragEventsTargetDocument.removeEventListener('mousemove', this._elementDragMove, true); this._dragEventsTargetDocument.removeEventListener('mouseup', this._elementDragEnd, true); if (this._dragEventsTargetDocumentTop && this._dragEventsTargetDocument !== this._dragEventsTargetDocumentTop) { this._dragEventsTargetDocumentTop.removeEventListener('mouseup', this._elementDragEnd, true); } delete this._dragEventsTargetDocument; delete this._dragEventsTargetDocumentTop; } /** * @param {!MouseEvent} event */ _elementDragMove(event) { if (event.buttons !== 1) { this._elementDragEnd(event); return; } if (this._elementDraggingEventListener && this._elementDraggingEventListener(event)) { this._cancelDragEvents(event); } } /** * @param {!Event} event */ _cancelDragEvents(event) { this._unregisterDragEvents(); this._unregisterMouseOutWhileDragging(); if (this._restoreCursorAfterDrag) { this._restoreCursorAfterDrag(); } this._disposeGlassPane(); delete this._elementDraggingEventListener; delete this._elementEndDraggingEventListener; } /** * @param {!Event} event */ _elementDragEnd(event) { const elementDragEnd = this._elementEndDraggingEventListener; this._cancelDragEvents(/** @type {!MouseEvent} */ (event)); event.preventDefault(); if (elementDragEnd) { elementDragEnd(/** @type {!MouseEvent} */ (event)); } } } DragHandler._glassPaneUsageCount = 0; /** @type {?GlassPane} */ DragHandler._glassPane = null; /** @type {?Document} */ DragHandler._documentForMouseOut = null; /** * @param {?Node=} node * @return {boolean} */ export function isBeingEdited(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) { return false; } const element = /** @type {!Element} */ (node); if (element.classList.contains('text-prompt') || element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { return true; } if (!elementsBeingEdited.size) { return false; } /** @type {?Element} */ let currentElement = element; while (currentElement) { if (elementsBeingEdited.has(element)) { return true; } currentElement = currentElement.parentElementOrShadowHost(); } return false; } /** * @return {boolean} */ export function isEditing() { if (elementsBeingEdited.size) { return true; } const focused = document.deepActiveElement(); if (!focused) { return false; } return focused.classList.contains('text-prompt') || focused.nodeName === 'INPUT' || focused.nodeName === 'TEXTAREA'; } /** * @param {!Element} element * @param {boolean} value * @return {boolean} */ export function markBeingEdited(element, value) { if (value) { if (elementsBeingEdited.has(element)) { return false; } element.classList.add('being-edited'); elementsBeingEdited.add(element); } else { if (!elementsBeingEdited.has(element)) { return false; } element.classList.remove('being-edited'); elementsBeingEdited.delete(element); } return true; } /** @type {!Set<!Element>} */ const elementsBeingEdited = new Set(); // Avoids Infinity, NaN, and scientific notation (e.g. 1e20), see crbug.com/81165. const _numberRegex = /^(-?(?:\d+(?:\.\d+)?|\.\d+))$/; export const StyleValueDelimiters = ' \xA0\t\n"\':;,/()'; /** * @param {!Event} event * @return {?string} */ export function getValueModificationDirection(event) { let direction = null; if (event.type === 'wheel') { // When shift is pressed while spinning mousewheel, delta comes as wheelDeltaX. const wheelEvent = /** @type {!WheelEvent} */ (event); if (wheelEvent.deltaY < 0 || wheelEvent.deltaX < 0) { direction = 'Up'; } else if (wheelEvent.deltaY > 0 || wheelEvent.deltaX > 0) { direction = 'Down'; } } else { const keyEvent = /** @type {!KeyboardEvent} */ (event); if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'PageUp') { direction = 'Up'; } else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'PageDown') { direction = 'Down'; } } return direction; } /** * @param {string} hexString * @param {!Event} event * @return {?string} */ function _modifiedHexValue(hexString, event) { const direction = getValueModificationDirection(event); if (!direction) { return null; } const mouseEvent = /** @type {!MouseEvent} */ (event); const number = parseInt(hexString, 16); if (isNaN(number) || !isFinite(number)) { return null; } const hexStrLen = hexString.length; const channelLen = hexStrLen / 3; // Colors are either rgb or rrggbb. if (channelLen !== 1 && channelLen !== 2) { return null; } // Precision modifier keys work with both mousewheel and up/down keys. // When ctrl is pressed, increase R by 1. // When shift is pressed, increase G by 1. // When alt is pressed, increase B by 1. // If no shortcut keys are pressed then increase hex value by 1. // Keys can be pressed together to increase RGB channels. e.g trying different shades. let delta = 0; if (KeyboardShortcut.eventHasCtrlOrMeta(mouseEvent)) { delta += Math.pow(16, channelLen * 2); } if (mouseEvent.shiftKey) { delta += Math.pow(16, channelLen); } if (mouseEvent.altKey) { delta += 1; } if (delta === 0) { delta = 1; } if (direction === 'Down') { delta *= -1; } // Increase hex value by 1 and clamp from 0 ... maxValue. const maxValue = Math.pow(16, hexStrLen) - 1; const result = Platform.NumberUtilities.clamp(number + delta, 0, maxValue); // Ensure the result length is the same as the original hex value. let resultString = result.toString(16).toUpperCase(); for (let i = 0, lengthDelta = hexStrLen - resultString.length; i < lengthDelta; ++i) { resultString = '0' + resultString; } return resultString; } /** * @param {number} number * @param {!Event} event * @param {number=} modifierMultiplier * @return {?number} */ function _modifiedFloatNumber(number, event, modifierMultiplier) { const direction = getValueModificationDirection(event); if (!direction) { return null; } const mouseEvent = /** @type {!MouseEvent} */ (event); // Precision modifier keys work with both mousewheel and up/down keys. // When ctrl is pressed, increase by 100. // When shift is pressed, increase by 10. // When alt is pressed, increase by 0.1. // Otherwise increase by 1. let delta = 1; if (KeyboardShortcut.eventHasCtrlOrMeta(mouseEvent)) { delta = 100; } else if (mouseEvent.shiftKey) { delta = 10; } else if (mouseEvent.altKey) { delta = 0.1; } if (direction === 'Down') { delta *= -1; } if (modifierMultiplier) { delta *= modifierMultiplier; } // Make the new number and constrain it to a precision of 6, this matches numbers the engine returns. // Use the Number constructor to forget the fixed precision, so 1.100000 will print as 1.1. const result = Number((number + delta).toFixed(6)); if (!String(result).match(_numberRegex)) { return null; } return result; } /** * @param {string} wordString * @param {!Event} event * @param {function(string, number, string):string=} customNumberHandler * @return {?string} */ export function createReplacementString(wordString, event, customNumberHandler) { let prefix; let suffix; let number; let replacementString = null; let matches = /(.*#)([\da-fA-F]+)(.*)/.exec(wordString); if (matches && matches.length) { prefix = matches[1]; suffix = matches[3]; number = _modifiedHexValue(matches[2], event); if (number !== null) { replacementString = prefix + number + suffix; } } else { matches = /(.*?)(-?(?:\d+(?:\.\d+)?|\.\d+))(.*)/.exec(wordString); if (matches && matches.length) { prefix = matches[1]; suffix = matches[3]; number = _modifiedFloatNumber(parseFloat(matches[2]), event); if (number !== null) { replacementString = customNumberHandler ? customNumberHandler(prefix, number, suffix) : prefix + number + suffix; } } } return replacementString; } /** * @param {!Event} event * @param {!Element} element * @param {function(string,string):void=} finishHandler * @param {(function(string):*)=} suggestionHandler * @param {function(string, number, string):string=} customNumberHandler * @return {boolean} */ export function handleElementValueModifications(event, element, finishHandler, suggestionHandler, customNumberHandler) { const arrowKeyOrWheelEvent = (/** @type {!KeyboardEvent} */ (event).key === 'ArrowUp' || /** @type {!KeyboardEvent} */ (event).key === 'ArrowDown' || event.type === 'wheel'); const pageKeyPressed = (/** @type {!KeyboardEvent} */ (event).key === 'PageUp' || /** @type {!KeyboardEvent} */ (event).key === 'PageDown'); if (!arrowKeyOrWheelEvent && !pageKeyPressed) { return false; } const selection = element.getComponentSelection(); if (!selection || !selection.rangeCount) { return false; } const selectionRange = selection.getRangeAt(0); if (!selectionRange.commonAncestorContainer.isSelfOrDescendant(element)) { return false; } const originalValue = element.textContent; const wordRange = DOMExtension.DOMExtension.rangeOfWord( selectionRange.startContainer, selectionRange.startOffset, StyleValueDelimiters, element); const wordString = wordRange.toString(); if (suggestionHandler && suggestionHandler(wordString)) { return false; } const replacementString = createReplacementString(wordString, event, customNumberHandler); if (replacementString) { const replacementTextNode = document.createTextNode(replacementString); wordRange.deleteContents(); wordRange.insertNode(replacementTextNode); const finalSelectionRange = document.createRange(); finalSelectionRange.setStart(replacementTextNode, 0); finalSelectionRange.setEnd(replacementTextNode, replacementString.length); selection.removeAllRanges(); selection.addRange(finalSelectionRange); event.handled = true; event.preventDefault(); if (finishHandler) { finishHandler(originalValue || '', replacementString); } return true; } return false; } /** * @param {number} ms * @param {number=} precision * @return {string} */ Number.preciseMillisToString = function(ms, precision) { precision = precision || 0; const format = '%.' + precision + 'f\xa0ms'; return Common.UIString.UIString(format, ms); }; /** @type {!Common.UIString.UIStringFormat} */ export const _microsFormat = new Common.UIString.UIStringFormat('%.0f\xa0\u03bcs'); /** @type {!Common.UIString.UIStringFormat} */ export const _subMillisFormat = new Common.UIString.UIStringFormat('%.2f\xa0ms'); /** @type {!Common.UIString.UIStringFormat} */ export const _millisFormat = new Common.UIString.UIStringFormat('%.0f\xa0ms'); /** @type {!Common.UIString.UIStringFormat} */ export const _secondsFormat = new Common.UIString.UIStringFormat('%.2f\xa0s'); /** @type {!Common.UIString.UIStringFormat} */ export const _minutesFormat = new Common.UIString.UIStringFormat('%.1f\xa0min'); /** @type {!Common.UIString.UIStringFormat} */ export const _hoursFormat = new Common.UIString.UIStringFormat('%.1f\xa0hrs'); /** @type {!Common.UIString.UIStringFormat} */ export const _daysFormat = new Common.UIString.UIStringFormat('%.1f\xa0days'); /** * @param {number} ms * @param {boolean=} higherResolution * @return {string} */ Number.millisToString = function(ms, higherResolution) { if (!isFinite(ms)) { return '-'; } if (ms === 0) { return '0'; } if (higherResolution && ms < 0.1) { return _microsFormat.format(ms * 1000); } if (higherResolution && ms < 1000) { return _subMillisFormat.format(ms); } if (ms < 1000) { return _millisFormat.format(ms); } const seconds = ms / 1000; if (seconds < 60) { return _secondsFormat.format(seconds); } const minutes = seconds / 60; if (minutes < 60) { return _minutesFormat.format(minutes); } const hours = minutes / 60; if (hours < 24) { return _hoursFormat.format(hours); } const days = hours / 24; return _daysFormat.format(days); }; /** * @param {number} seconds * @param {boolean=} higherResolution * @return {string} */ Number.secondsToString = function(seconds, higherResolution) { if (!isFinite(seconds)) { return '-'; } return Number.millisToString(seconds * 1000, higherResolution); }; /** * @param {number} num * @return {string} */ Number.withThousandsSeparator = function(num) { let str = String(num); const re = /(\d+)(\d{3})/; while (str.match(re)) { str = str.replace(re, '$1\xA0$2'); } // \xa0 is a non-breaking space return str; }; /** * @param {string} format * @param {?ArrayLike<*>} substitutions * @return {!Element} */ export function formatLocalized(format, substitutions) { const formatters = {s: /** @param {*} substitution */ substitution => substitution}; /** * @param {!Element} a * @param {*} b * @return {!Element} */ function append(a, b) { a.appendChild(typeof b === 'string' ? document.createTextNode(b) : /** @type {!Element} */ (b)); return a; } return Platform.StringUtilities .format(Common.UIString.UIString(format), substitutions, formatters, document.createElement('span'), append) .formattedResult; } /** * @return {string} */ export function openLinkExternallyLabel() { return Common.UIString.UIString('Open in new tab'); } /** * @return {string} */ export function copyLinkAddressLabel() { return Common.UIString.UIString('Copy link address'); } /** * @return {string} */ export function copyFileNameLabel() { return ls`Copy file name`; } /** * @return {string} */ export function anotherProfilerActiveLabel() { return Common.UIString.UIString('Another profiler is already active'); } /** * @param {string|undefined} description * @return {string} */ export function asyncStackTraceLabel(description) { if (description) { if (description === 'Promise.resolve') { return ls`Promise resolved (async)`; } if (description === 'Promise.reject') { return ls`Promise rejected (async)`; } return ls`${description} (async)`; } return Common.UIString.UIString('Async Call'); } /** * @param {!Element} element */ export function installComponentRootStyles(element) { injectCoreStyles(element); element.classList.add('platform-' + Host.Platform.platform()); // Detect overlay scrollbar enable by checking for nonzero scrollbar width. if (!Host.Platform.isMac() && measuredScrollbarWidth(element.ownerDocument) === 0) { element.classList.add('overlay-scrollbar-enabled'); } } /** * @param {!Document} document * @param {!Event} event */ function _windowFocused(document, event) { if (event.target instanceof Window && event.target.document.nodeType === Node.DOCUMENT_NODE) { document.body.classList.remove('inactive'); } } /** * @param {!Document} document * @param {!Event} event */ function _windowBlurred(document, event) { if (event.target instanceof Window && event.target.document.nodeType === Node.DOCUMENT_NODE) { document.body.classList.add('inactive'); } } export class ElementFocusRestorer { /** * @param {!Element} element */ constructor(element) { /** @type {?HTMLElement} */ this._element = /** @type {?HTMLElement} */ (element); /** @type {?HTMLElement} */ this._previous = /** @type {?HTMLElement} */ (element.ownerDocument.deepActiveElement()); /** @type {!HTMLElement} */ (element).focus(); } restore() { if (!this._element) { return; } if (this._element.hasFocus() && this._previous) { this._previous.focus(); } this._previous = null; this._element = null; } } /** * @param {!Element} element * @param {number} offset * @param {number} length * @param {!Array.<*>=} domChanges * @return {?Element} */ export function highlightSearchResult(element, offset, length, domChanges) { const result = highlightSearchResults(element, [new TextUtils.TextRange.SourceRange(offset, length)], domChanges); return result.length ? result[0] : null; } /** * @param {!Element} element * @param {!Array.<!TextUtils.TextRange.SourceRange>} resultRanges * @param {!Array.<!HighlightChange>=} changes * @return {!Array.<!Element>} */ export function highlightSearchResults(element, resultRanges, changes) { return highlightRangesWithStyleClass(element, resultRanges, highlightedSearchResultClassName, changes); } /** * @param {!Element} element * @param {string} className */ export function runCSSAnimationOnce(element, className) { function animationEndCallback() { element.classList.remove(className); element.removeEventListener('webkitAnimationEnd', animationEndCallback, false); } if (element.classList.contains(className)) { element.classList.remove(className); } element.addEventListener('webkitAnimationEnd', animationEndCallback, false); element.classList.add(className); } /** * @param {!Element} element * @param {!Array.<!TextUtils.TextRange.SourceRange>} resultRanges * @param {string} styleClass * @param {!Array.<!HighlightChange>=} changes * @return {!Array.<!Element>} */ export function highlightRangesWithStyleClass(element, resultRanges, styleClass, changes) { changes = changes || []; /** @type {!Array<!Element>} */ const highlightNodes = []; const textNodes = element.childTextNodes(); const lineText = textNodes .map(function(node) { return node.textContent; }) .join(''); const ownerDocument = element.ownerDocument; if (textNodes.length === 0) { return highlightNodes; } const nodeRanges = []; let rangeEndOffset = 0; for (const textNode of textNodes) { const range = {}; range.offset = rangeEndOffset; range.length = textNode.textContent ? textNode.textContent.length : 0; rangeEndOffset = range.offset + range.length; nodeRanges.push(range); } let startIndex = 0; for (let i = 0; i < resultRanges.length; ++i) { const startOffset = resultRanges[i].offset; const endOffset = startOffset + resultRanges[i].length; while (startIndex < textNodes.length && nodeRanges[startIndex].offset + nodeRanges[startIndex].length <= startOffset) { startIndex++; } let endIndex = startIndex; while (endIndex < textNodes.length && nodeRanges[endIndex].offset + nodeRanges[endIndex].length < endOffset) { endIndex++; } if (endIndex === textNodes.length) { break; } const highlightNode = ownerDocument.createElement('span'); highlightNode.className = styleClass; highlightNode.textContent = lineText.substring(startOffset, endOffset); const lastTextNode = textNodes[endIndex]; const lastText = lastTextNode.textContent || ''; lastTextNode.textContent = lastText.substring(endOffset - nodeRanges[endIndex].offset); changes.push({ node: /** @type {!Element} */ (lastTextNode), type: 'changed', oldText: lastText, newText: lastTextNode.textContent, nextSibling: undefined, parent: undefined }); if (startIndex === endIndex && lastTextNode.parentElement) { lastTextNode.parentElement.insertBefore(highlightNode, lastTextNode); changes.push({ node: highlightNode, type: 'added', nextSibling: lastTextNode, parent: lastTextNode.parentElement, oldText: undefined, newText: undefined }); highlightNodes.push(highlightNode); const prefixNode = ownerDocument.createTextNode(lastText.substring(0, startOffset - nodeRanges[startIndex].offset)); lastTextNode.parentElement.insertBefore(prefixNode, highlightNode); changes.push({ node: /** @type {*} */ (prefixNode), type: 'added', nextSibling: highlightNode, parent: lastTextNode.parentElement, oldText: undefined, newText: undefined }); } else { const firstTextNode = textNodes[startIndex]; const firstText = firstTextNode.textContent || ''; const anchorElement = firstTextNode.nextSibling; if (firstTextNode.parentElement) { firstTextNode.parentElement.insertBefore(highlightNode, anchorElement); changes.push({ node: highlightNode, type: 'added', nextSibling: anchorElement || undefined, parent: firstTextNode.parentElement, oldText: undefined, newText: undefined }); highlightNodes.push(highlightNode); } firstTextNode.textContent = firstText.substring(0, startOffset - nodeRanges[startIndex].offset); changes.push({ node: /** @type {!Element} */ (firstTextNode), type: 'changed', oldText: firstText, newText: firstTextNode.textContent, nextSibling: undefined, parent: undefined }); for (let j = startIndex + 1; j < endIndex; j++) { const textNode = textNodes[j]; const text = textNode.textContent; textNode.textContent = ''; changes.push({ node: /** @type {!Element} */ (textNode), type: 'changed', oldText: text || undefined, newText: textNode.textContent, nextSibling: undefined, parent: undefined }); } } startIndex = endIndex; nodeRanges[startIndex].offset = endOffset; nodeRanges[startIndex].length = lastTextNode.textContent.length; } return highlightNodes; } /** @param {!Array<*>} domChanges */ export function applyDomChanges(domChanges) { for (let i = 0, size = domChanges.length; i < size; ++i) { const entry = domChanges[i]; switch (entry.type) { case 'added': entry.parent.insertBefore(entry.node, entry.nextSibling); break; case 'changed': entry.node.textContent = entry.newText; break; } } } /** @param {!Array<*>} domChanges */ export function revertDomChanges(domChanges) { for (let i = domChanges.length - 1; i >= 0; --i) { const entry = domChanges[i]; switch (entry.type) { case 'added': entry.node.remove(); break; case 'changed': entry.node.textContent = entry.oldText; break; } } } /** * @param {!Element} element * @param {?Element=} containerElement * @return {!Size} */ export function measurePreferredSize(element, containerElement) { const oldParent = element.parentElement; const oldNextSibling = element.nextSibling; containerElement = containerElement || element.ownerDocument.body; containerElement.appendChild(element); element.positionAt(0, 0); const result = element.getBoundingClientRect(); element.positionAt(undefined, undefined); if (oldParent) { oldParent.insertBefore(element, oldNextSibling); } else { element.remove(); } return new Size(result.width, result.height); } class InvokeOnceHandlers { /** * @param {boolean} autoInvoke */ constructor(autoInvoke) { this._handlers = null; this._autoInvoke = autoInvoke; } /** * @param {!Object} object * @param {function():void} method */ add(object, method) { if (!this._handlers) { this._handlers = new Map(); if (this._autoInvoke) { this.scheduleInvoke(); } } let methods = this._handlers.get(object); if (!methods) { methods = new Set(); this._handlers.set(object, methods); } methods.add(method); } scheduleInvoke() { if (this._handlers) { requestAnimationFrame(this._invoke.bind(this)); } } _invoke() { const handlers = this._handlers || new Map(); // Make closure happy. This should not be null. this._handlers = null; for (const [object, methods] of handlers) { for (const method of methods) { method.call(object); } } } } let _coalescingLevel = 0; /** @type {?InvokeOnceHandlers} */ let _postUpdateHandlers = null; export function startBatchUpdate() { if (!_coalescingLevel++) { _postUpdateHandlers = new InvokeOnceHandlers(false); } } export function endBatchUpdate() { if (--_coalescingLevel) { return; } if (_postUpdateHandlers) { _postUpdateHandlers.scheduleInvoke(); _postUpdateHandlers = null; } } /** * @param {!Object} object * @param {function():void} method */ export function invokeOnceAfterBatchUpdate(object, method) { if (!_postUpdateHandlers) { _postUpdateHandlers = new InvokeOnceHandlers(true); } _postUpdateHandlers.add(object, method); } /** * @param {!Window} window * @param {!Function} func * @param {!Array.<{from:number, to:number}>} params * @param {number} duration * @param {function():*=} animationComplete * @return {function():void} */ export function animateFunction(window, func, params, duration, animationComplete) { const start = window.performance.now(); let raf = window.requestAnimationFrame(animationStep); /** @param {number} timestamp */ function animationStep(timestamp) { const progress = Platform.NumberUtilities.clamp((timestamp - start) / duration, 0, 1); func(...params.map(p => p.from + (p.to - p.from) * progress)); if (progress < 1) { raf = window.requestAnimationFrame(animationStep); } else if (animationComplete) { animationComplete(); } } return () => window.cancelAnimationFrame(raf); } export class LongClickController extends Common.ObjectWrapper.ObjectWrapper { /** * @param {!Element} element * @param {function(!Event):void} callback * @param {function(!Event):boolean} isEditKeyFunc */ constructor(element, callback, isEditKeyFunc = event => isEnterOrSpaceKey(event)) { super(); this._element = element; this._callback = callback; this._editKey = isEditKeyFunc; this._enable(); /** @type {({mouseUp: function(!Event):void, mouseDown: function(!Event):void, reset: function():void}|undefined)}} */ this._longClickData; /** @type {(number|undefined)} */ this._longClickInterval; } reset() { if (this._longClickInterval) { clearInterval(this._longClickInterval); delete this._longClickInterval; } } _enable() { if (this._longClickData) { return; } const boundKeyDown = keyDown.bind(this); const boundKeyUp = keyUp.bind(this); const boundMouseDown = mouseDown.bind(this); const boundMouseUp = mouseUp.bind(this); const boundReset = this.reset.bind(this); this._element.addEventListener('keydown', boundKeyDown, false); this._element.addEventListener('keyup', boundKeyUp, false); this._element.addEventListener('mousedown', boundMouseDown, false); this._element.addEventListener('mouseout', boundReset, false); this._element.addEventListener('mouseup', boundMouseUp, false); this._element.addEventListener('click', boundReset, true); this._longClickData = {mouseUp: boundMouseUp, mouseDown: boundMouseDown, reset: boundReset}; /** * @param {!Event} e * @this {LongClickController} */ function keyDown(e) { if (this._editKey(e)) { const callback = this._callback; this._longClickInterval = window.setTimeout(callback.bind(null, e), LongClickController.TIME_MS); } } /** * @param {!Event} e * @this {LongClickController} */ function keyUp(e) { if (this._editKey(e)) { this.reset(); } } /** * @param {!Event} e * @this {LongClickController} */ function mouseDown(e) { if (/** @type {!MouseEvent} */ (e).which !== 1) { return; } const callback = this._callback; this._longClickInterval = window.setTimeout(callback.bind(null, e), LongClickController.TIME_MS); } /** * @param {!Event} e * @this {LongClickController} */ function mouseUp(e) { if (/** @type {!MouseEvent} */ (e).which !== 1) { return; } this.reset(); } } dispose() { if (!this._longClickData) { return; } this._element.removeEventListener('mousedown', this._longClickData.mouseDown, false); this._element.removeEventListener('mouseout', this._longClickData.reset, false); this._element.removeEventListener('mouseup', this._longClickData.mouseUp, false); this._element.addEventListener('click', this._longClickData.reset, true); delete this._longClickData; } } LongClickController.TIME_MS = 200; /** * @param {!Document} document * @param {!Common.Settings.Setting<string>} themeSetting */ export function initializeUIUtils(document, themeSetting) { document.body.classList.toggle('inactive', !document.hasFocus()); if (document.defaultView) { document.defaultView.addEventListener('focus', _windowFocused.bind(undefined, document), false); document.defaultView.addEventListener('blur', _windowBlurred.bind(undefined, document), false); } document.addEventListener('focus', focusChanged.bind(undefined), true); if (!ThemeSupport.ThemeSupport.hasInstance()) { ThemeSupport.ThemeSupport.instance({forceNew: true, setting: themeSetting}); } ThemeSupport.ThemeSupport.instance().applyTheme(document); const body = /** @type {!Element} */ (document.body); appendStyle(body, 'ui/inspectorStyle.css', {enableLegacyPatching: true}); appendStyle(body, 'ui/themeColors.css', {enableLegacyPatching: false}); GlassPane.setContainer(/** @type {!Element} */ (document.body)); } /** * @param {string} name * @return {string} */ export function beautifyFunctionName(name) { return name || Common.UIString.UIString('(anonymous)'); } /** * @param {!Element|!DocumentFragment} element * @param {string} text * @return {!Text} */ export const createTextChild = (element, text) => { const textNode = element.ownerDocument.createTextNode(text); element.appendChild(textNode); return textNode; }; /** * @param {!Element|!DocumentFragment} element * @param {...string} childrenText */ export const createTextChildren = (element, ...childrenText) => { for (const child of childrenText) { createTextChild(element, child); } }; /** * @param {string} text * @param {function(!Event):*=} eventHandler * @param {string=} className * @param {boolean=} primary * @param {string=} alternativeEvent * @return {!HTMLButtonElement} */ export function createTextButton(text, eventHandler, className, primary, alternativeEvent) { const element = /** @type {!HTMLButtonElement} */ (document.createElement('button')); if (className) { element.className = className; } element.textContent = text; element.classList.add('text-button'); if (primary) { element.classList.add('primary-button'); } if (eventHandler) { element.addEventListener(alternativeEvent || 'click', eventHandler); } element.type = 'button'; return element; } /** * @param {string=} className * @param {string=} type * @return {!HTMLInputElement} */ export function createInput(className, type) { const element = document.createElement('input'); if (className) { element.className = className; } element.spellcheck = false; element.classList.add('harmony-input'); if (type) { element.type = type; } return /** @type {!HTMLInputElement} */ (element); } /** * @param {string} name * @param {!Array<!Map<string, !Array<string>>> | !Array<string> | !Set<string>} options * @return {!HTMLSelectElement} */ export function createSelect(name, options) { const select = /** @type {!HTMLSelectElement} */ (document.createElementWithClass('select', 'chrome-select')); ARIAUtils.setAccessibleName(select, name); for (const option of options) { if (option instanceof Map) { for (const [key, value] of option) { const optGroup = /** @type {!HTMLOptGroupElement} */ (select.createChild('optgroup')); optGroup.label = key; for (const child of value) { if (typeof child === 'string') { optGroup.appendChild(new Option(child, child)); } } } } else if (typeof option === 'string') { select.add(new Option(option, option)); } } return select; } /** * @param {string} title * @param {string=} className * @param {!Element=} associatedControl * @return {!Element} */ export function createLabel(title, className, associatedControl) { const element = document.createElement('label'); if (className) { element.className = className; } element.textContent = title; if (associatedControl) { ARIAUtils.bindLabelToControl(element, associatedControl); } return element; } /** * @param {string} name * @param {string} title * @param {boolean=} checked * @return {!DevToolsRadioButton} */ export function createRadioLabel(name, title, checked) { const element = /** @type {!DevToolsRadioButton} */ (document.createElement('span', {is: 'dt-radio'})); element.radioElement.name = name; element.radioElement.checked = Boolean(checked); createTextChild(element.labelElement, title); return element; } /** * @param {string} title * @param {string} iconClass * @return {!HTMLElement} */ export function createIconLabel(title, iconClass) { const element = /** @type {!DevToolsIconLabel} */ (document.createElement('span', {is: 'dt-icon-label'})); element.createChild('span').textContent = title; element.type = iconClass; return element; } /** * @param {number} min * @param {number} max * @param {number} tabIndex * @return {!Element} */ export function createSlider(min, max, tabIndex) { const element = /** @type {!DevToolsSlider} */ (document.createElement('span', {is: 'dt-slider'})); element.sliderElement.min = String(min); element.sliderElement.max = String(max); element.sliderElement.step = String(1); element.sliderElement.tabIndex = tabIndex; return element; } /** * @param {!HTMLElement} element * @param {string} title * @param {string | undefined} actionId */ export function setTitle(element, title, actionId = undefined) { ARIAUtils.setAccessibleName(element, title); Tooltip.install(element, title, actionId, { anchorTooltipAtElement: true, }); } export class CheckboxLabel extends HTMLSpanElement { constructor() { super(); /** @type {!DocumentFragment} */ this._shadowRoot; /** @type {!HTMLInputElement} */ this.checkboxElement; /** @type {!Element} */ this.textElement; CheckboxLabel._lastId = CheckboxLabel._lastId + 1; const id = 'ui-checkbox-label' + CheckboxLabel._lastId; this._shadowRoot = createShadowRootWithCoreStyles( this, {cssFile: 'ui/checkboxTextLabel.css', enableLegacyPatching: true, delegatesFocus: undefined}); this.checkboxElement = /** @type {!HTMLInputElement} */ (this._shadowRoot.createChild('input')); this.checkboxElement.type = 'checkbox'; this.checkboxElement.setAttribute('id', id); this.textElement = this._shadowRoot.createChild('label', 'dt-checkbox-text'); this.textElement.setAttribute('for', id); this._shadowRoot.createChild('slot'); } /** * @param {string=} title * @param {boolean=} checked * @param {string=} subtitle * @return {!CheckboxLabel} */ static create(title, checked, subtitle) { if (!CheckboxLabel._constructor) { CheckboxLabel._constructor = registerCustomElement('span', 'dt-checkbox', CheckboxLabel); } const element = /** @type {!CheckboxLabel} */ (CheckboxLabel._constructor()); element.checkboxElement.checked = Boolean(checked); if (title !== undefined) { element.textElement.textContent = title; ARIAUtils.setAccessibleName(element.checkboxElement, title); if (subtitle !== undefined) { element.textElement.createChild('div', 'dt-checkbox-subtitle').textContent = subtitle; } } return element; } /** * @param {string} color */ set backgroundColor(color) { this.checkboxElement.classList.add('dt-checkbox-themed'); this.checkboxElement.style.backgroundColor = color; } /** * @param {string} color */ set checkColor(color) { this.checkboxElement.classList.add('dt-checkbox-themed'); const stylesheet = document.createElement('style'); stylesheet.textContent = 'input.dt-checkbox-themed:checked:after { background-color: ' + color + '}'; this._shadowRoot.appendChild(stylesheet); } /** * @param {string} color */ set borderColor(color) { this.checkboxElement.classList.add('dt-checkbox-themed'); this.checkboxElement.style.borderColor = color; } } /** @type {number} */ CheckboxLabel._lastId = 0; /** @type {?function():Element} */ CheckboxLabel._constructor = null; export class DevToolsIconLabel extends HTMLSpanElement { constructor() { super(); const root = createShadowRootWithCoreStyles(this, { enableLegacyPatching: true, cssFile: undefined, delegatesFocus: undefined, }); this._iconElement = Icon.create(); this._iconElement.style.setProperty('margin-right', '4px'); root.appendChild(this._iconElement); root.createChild('slot'); } /** * @param {string} type */ set type(type) { this._iconElement.setIconType(type); } } let labelId = 0; export class DevToolsRadioButton extends HTMLSpanElement { constructor() { super(); /** @type {!HTMLInputElement} */ this.radioElement = /** @type {!HTMLInputElement} */ (this.createChild('input', 'dt-radio-button')); /** @type {!HTMLLabelElement} */ this.labelElement = /** @type {!HTMLLabelElement} */ (this.createChild('label')); const id = 'dt-radio-button-id' + (++labelId); this.radioElement.id = id; this.radioElement.type = 'radio'; this.labelElement.htmlFor = id; const root = createShadowRootWithCoreStyles( this, {cssFile: 'ui/radioButton.css', enableLegacyPatching: true, delegatesFocus: undefined}); root.createChild('slot'); this.addEventListener('click', this.radioClickHandler.bind(this), false); } radioClickHandler() { if (this.radioElement.checked || this.radioElement.disabled) { return; } this.radioElement.checked = true; this.radioElement.dispatchEvent(new Event('change')); } } registerCustomElement('span', 'dt-radio', DevToolsRadioButton); registerCustomElement('span', 'dt-icon-label', DevToolsIconLabel); export class DevToolsSlider extends HTMLSpanElement { constructor() { super(); const root = createShadowRootWithCoreStyles( this, {cssFile: 'ui/slider.css', enableLegacyPatching: true, delegatesFocus: undefined}); this.sliderElement = document.createElement('input'); this.sliderElement.classList.add('dt-range-input'); this.sliderElement.type = 'range'; root.appendChild(this.sliderElement); } /** * @param {number} amount */ set value(amount) { this.sliderElement.value = String(amount); } get value() { return Number(this.sliderElement.value); } } registerCustomElement('span', 'dt-slider', DevToolsSlider); export class DevToolsSmallBubble extends HTMLSpanElement { constructor() { super(); const root = createShadowRootWithCoreStyles( this, {cssFile: 'ui/smallBubble.css', enableLegacyPatching: true, delegatesFocus: undefined}); this._textElement = root.createChild('div'); this._textElement.className = 'info'; this._textElement.createChild('slot'); } /** * @param {string} type */ set type(type) { this._textElement.className = type; } } registerCustomElement('span', 'dt-small-bubble', DevToolsSmallBubble); export class DevToolsCloseButton extends HTMLDivElement { constructor() { super(); const root = createShadowRootWithCoreStyles( this, {cssFile: 'ui/closeButton.css', enableLegacyPatching: false, delegatesFocus: undefined}); /** @type {!HTMLElement} */ this._buttonElement = /** @type {!HTMLElement} */ (root.createChild('div', 'close-button')); ARIAUtils.setAccessibleName(this._buttonElement, ls`Close`); ARIAUtils.markAsButton(this._buttonElement); const regularIcon = Icon.create('smallicon-cross', 'default-icon'); this._hoverIcon = Icon.create('mediumicon-red-cross-hover', 'hover-icon'); this._activeIcon = Icon.create('mediumicon-red-cross-active', 'active-icon'); this._buttonElement.appendChild(regularIcon); this._buttonElement.appendChild(this._hoverIcon); this._buttonElement.appendChild(this._activeIcon); } /** * @param {boolean} gray */ set gray(gray) { if (gray) { this._hoverIcon.setIconType('mediumicon-gray-cross-hover'); this._activeIcon.setIconType('mediumicon-gray-cross-active'); } else { this._hoverIcon.setIconType('mediumicon-red