UNPKG

chrome-devtools-frontend

Version:
1,495 lines (1,312 loc) 76.3 kB
// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-imperative-dom-api */ /* * 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 './Toolbar.js'; import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Geometry from '../../models/geometry/geometry.js'; import * as Buttons from '../components/buttons/buttons.js'; import {Icon, type IconData} from '../kit/kit.js'; import * as Lit from '../lit/lit.js'; import * as VisualLogging from '../visual_logging/visual_logging.js'; import * as ActionRegistration from './ActionRegistration.js'; import {ActionRegistry} from './ActionRegistry.js'; import * as ARIAUtils from './ARIAUtils.js'; import checkboxTextLabelStyles from './checkboxTextLabel.css.js'; import confirmDialogStyles from './confirmDialog.css.js'; import {Dialog} from './Dialog.js'; import {appendStyle, deepActiveElement, rangeOfWord} from './DOMUtilities.js'; import {GlassPane, PointerEventsBehavior, SizeBehavior} from './GlassPane.js'; import inspectorCommonStyles from './inspectorCommon.css.js'; import {InspectorView} from './InspectorView.js'; import {KeyboardShortcut, Keys} from './KeyboardShortcut.js'; import smallBubbleStyles from './smallBubble.css.js'; import {Tooltip} from './Tooltip.js'; import {Widget} from './Widget.js'; declare global { interface HTMLElementTagNameMap { 'devtools-checkbox': CheckboxLabel; 'dt-close-button': DevToolsCloseButton; 'dt-icon-label': DevToolsIconLabel; 'dt-small-bubble': DevToolsSmallBubble; } } const {Directives, render} = Lit; const UIStrings = { /** * @description label to open link externally */ openInNewTab: 'Open in new tab', /** * @description label to copy link address */ copyLinkAddress: 'Copy link address', /** * @description label to copy file name */ copyFileName: 'Copy file name', /** * @description label for the profiler control button */ anotherProfilerIsAlreadyActive: 'Another profiler is already active', /** * @description Text in UIUtils */ promiseResolvedAsync: 'Promise resolved (async)', /** * @description Text in UIUtils */ promiseRejectedAsync: 'Promise rejected (async)', /** * @description Text for the title of asynchronous function calls group in Call Stack */ asyncCall: 'Async Call', /** * @description Text for the name of anonymous functions */ anonymous: '(anonymous)', /** * @description Text to close something */ close: 'Close', /** * @description Text on a button for message dialog */ ok: 'OK', /** * @description Text to cancel something */ cancel: 'Cancel', /** * @description Text for the new badge appearing next to some menu items */ new: 'NEW', } as const; const str_ = i18n.i18n.registerUIStrings('ui/legacy/UIUtils.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export function installDragHandle( element: Element, elementDragStart: ((arg0: MouseEvent) => boolean)|null, elementDrag: (arg0: MouseEvent) => void, elementDragEnd: ((arg0: MouseEvent) => void)|null, cursor: string|null, hoverCursor?: string|null, startDelay?: number, mouseDownPreventDefault = true): void { function onMouseDown(event: Event): void { const dragHandler = new DragHandler(); const dragStart = (): void => dragHandler.elementDragStart( element, elementDragStart, elementDrag, elementDragEnd, cursor, event, mouseDownPreventDefault); if (startDelay) { startTimer = window.setTimeout(dragStart, startDelay); } else { dragStart(); } } function onMouseUp(): void { if (startTimer) { window.clearTimeout(startTimer); } startTimer = null; } let startTimer: number|null; element.addEventListener('pointerdown', onMouseDown, false); if (startDelay) { element.addEventListener('pointerup', onMouseUp, false); } if (hoverCursor !== null) { (element as HTMLElement).style.cursor = hoverCursor || cursor || ''; } } export function elementDragStart( targetElement: Element, elementDragStart: ((arg0: MouseEvent) => boolean)|null, elementDrag: (arg0: MouseEvent) => void, elementDragEnd: ((arg0: MouseEvent) => void)|null, cursor: string|null, event: Event): void { const dragHandler = new DragHandler(); dragHandler.elementDragStart(targetElement, elementDragStart, elementDrag, elementDragEnd, cursor, event); } class DragHandler { private glassPaneInUse?: boolean; private elementDraggingEventListener?: ((arg0: MouseEvent) => void|boolean); private elementEndDraggingEventListener?: ((arg0: MouseEvent) => void)|null; private dragEventsTargetDocument?: Document; private dragEventsTargetDocumentTop?: Document; private restoreCursorAfterDrag?: (() => void); constructor() { this.elementDragMove = this.elementDragMove.bind(this); this.elementDragEnd = this.elementDragEnd.bind(this); this.mouseOutWhileDragging = this.mouseOutWhileDragging.bind(this); } private createGlassPane(): void { this.glassPaneInUse = true; if (!DragHandler.glassPaneUsageCount++) { DragHandler.glassPane = new GlassPane(); DragHandler.glassPane.setPointerEventsBehavior(PointerEventsBehavior.BLOCKED_BY_GLASS_PANE); if (DragHandler.documentForMouseOut) { DragHandler.glassPane.show(DragHandler.documentForMouseOut); } } } private disposeGlassPane(): void { if (!this.glassPaneInUse) { return; } this.glassPaneInUse = false; if (--DragHandler.glassPaneUsageCount) { return; } if (DragHandler.glassPane) { DragHandler.glassPane.hide(); DragHandler.glassPane = null; } DragHandler.documentForMouseOut = null; DragHandler.rootForMouseOut = null; } elementDragStart( targetElement: Element, elementDragStart: ((arg0: MouseEvent) => boolean)|null, elementDrag: (arg0: MouseEvent) => void|boolean, elementDragEnd: ((arg0: MouseEvent) => void)|null, cursor: string|null, ev: Event, preventDefault = true): void { const event = (ev as MouseEvent); // 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((event))) { return; } const targetDocument = (event.target instanceof Node && event.target.ownerDocument) as Document; this.elementDraggingEventListener = elementDrag; this.elementEndDraggingEventListener = elementDragEnd; console.assert( (DragHandler.documentForMouseOut || targetDocument) === targetDocument, 'Dragging on multiple documents.'); DragHandler.documentForMouseOut = targetDocument; DragHandler.rootForMouseOut = event.target instanceof Node && event.target.getRootNode() || null; this.dragEventsTargetDocument = targetDocument; try { if (targetDocument.defaultView && targetDocument.defaultView.top) { this.dragEventsTargetDocumentTop = targetDocument.defaultView.top.document; } } catch { this.dragEventsTargetDocumentTop = this.dragEventsTargetDocument; } targetDocument.addEventListener('pointermove', this.elementDragMove, true); targetDocument.addEventListener('pointerup', this.elementDragEnd, true); DragHandler.rootForMouseOut?.addEventListener('pointerout', this.mouseOutWhileDragging, {capture: true}); if (this.dragEventsTargetDocumentTop && targetDocument !== this.dragEventsTargetDocumentTop) { this.dragEventsTargetDocumentTop.addEventListener('pointerup', this.elementDragEnd, true); } const targetHtmlElement = (targetElement as HTMLElement); if (typeof cursor === 'string') { this.restoreCursorAfterDrag = restoreCursor.bind(this, targetHtmlElement.style.cursor); targetHtmlElement.style.cursor = cursor; targetDocument.body.style.cursor = cursor; } function restoreCursor(this: DragHandler, oldCursor: string): void { targetDocument.body.style.removeProperty('cursor'); targetHtmlElement.style.cursor = oldCursor; this.restoreCursorAfterDrag = undefined; } if (preventDefault) { event.preventDefault(); } } private mouseOutWhileDragging(): void { this.unregisterMouseOutWhileDragging(); this.createGlassPane(); } private unregisterMouseOutWhileDragging(): void { if (!DragHandler.rootForMouseOut) { return; } DragHandler.rootForMouseOut.removeEventListener('pointerout', this.mouseOutWhileDragging, {capture: true}); } private unregisterDragEvents(): void { if (!this.dragEventsTargetDocument) { return; } this.dragEventsTargetDocument.removeEventListener('pointermove', this.elementDragMove, true); this.dragEventsTargetDocument.removeEventListener('pointerup', this.elementDragEnd, true); if (this.dragEventsTargetDocumentTop && this.dragEventsTargetDocument !== this.dragEventsTargetDocumentTop) { this.dragEventsTargetDocumentTop.removeEventListener('pointerup', this.elementDragEnd, true); } delete this.dragEventsTargetDocument; delete this.dragEventsTargetDocumentTop; } private elementDragMove(event: MouseEvent): void { if (event.buttons !== 1) { this.elementDragEnd(event); return; } if (this.elementDraggingEventListener?.(event)) { this.cancelDragEvents(event); } } private cancelDragEvents(_event: Event): void { this.unregisterDragEvents(); this.unregisterMouseOutWhileDragging(); if (this.restoreCursorAfterDrag) { this.restoreCursorAfterDrag(); } this.disposeGlassPane(); delete this.elementDraggingEventListener; delete this.elementEndDraggingEventListener; } private elementDragEnd(event: Event): void { const elementDragEnd = this.elementEndDraggingEventListener; this.cancelDragEvents((event as MouseEvent)); event.preventDefault(); if (elementDragEnd) { elementDragEnd((event as MouseEvent)); } } private static glassPaneUsageCount = 0; private static glassPane: GlassPane|null = null; private static documentForMouseOut: Document|null = null; private static rootForMouseOut: Node|null = null; } export function isBeingEdited(node?: Node|null): boolean { if (!node || node.nodeType !== Node.ELEMENT_NODE) { return false; } const element = (node as Element); if (element.classList.contains('text-prompt') || element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { return true; } if (!elementsBeingEdited.size) { return false; } let currentElement: (Element|null)|Element = element; while (currentElement) { if (elementsBeingEdited.has(element)) { return true; } currentElement = currentElement.parentElementOrShadowHost(); } return false; } export function isEditing(): boolean { if (elementsBeingEdited.size) { return true; } const focused = deepActiveElement(document); if (!focused) { return false; } return focused.classList.contains('text-prompt') || focused.nodeName === 'INPUT' || focused.nodeName === 'TEXTAREA' || ((focused as HTMLElement).contentEditable === 'true' || (focused as HTMLElement).contentEditable === 'plaintext-only'); } export function markBeingEdited(element: Element, value: boolean): boolean { 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; } const elementsBeingEdited = new Set<Element>(); // Avoids Infinity, NaN, and scientific notation (e.g. 1e20), see crbug.com/81165. const numberRegex = /^(-?(?:\d+(?:\.\d+)?|\.\d+))$/; export const StyleValueDelimiters = ' \xA0\t\n"\':;,/()'; export function getValueModificationDirection(event: Event): string|null { let direction: 'Up'|'Down'|null = null; if (event instanceof WheelEvent) { // When shift is pressed while spinning mousewheel, delta comes as wheelDeltaX. if (event.deltaY < 0 || event.deltaX < 0) { direction = 'Up'; } else if (event.deltaY > 0 || event.deltaX > 0) { direction = 'Down'; } } else if (event instanceof MouseEvent) { if (event.movementX < 0) { direction = 'Down'; } else if (event.movementX > 0) { direction = 'Up'; } } else if (event instanceof KeyboardEvent) { if (event.key === 'ArrowUp' || event.key === 'PageUp') { direction = 'Up'; } else if (event.key === 'ArrowDown' || event.key === 'PageDown') { direction = 'Down'; } } return direction; } function modifiedHexValue(hexString: string, event: Event): string|null { const direction = getValueModificationDirection(event); if (!direction) { return null; } const mouseEvent = (event as MouseEvent); 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.eventHasCtrlEquivalentKey(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; } export function modifiedFloatNumber( number: number, event: Event, modifierMultiplier?: number, range?: {min?: number, max?: number}): number|null { const direction = getValueModificationDirection(event); if (!direction) { return null; } const mouseEvent = (event as MouseEvent); // 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 = mouseEvent.type === 'mousemove' ? Math.abs(mouseEvent.movementX) : 1; if (KeyboardShortcut.eventHasCtrlEquivalentKey(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. let result = Number((number + delta).toFixed(6)); if (range?.min !== undefined) { result = Math.max(result, range.min); } if (range?.max !== undefined) { result = Math.min(result, range.max); } if (!String(result).match(numberRegex)) { return null; } return result; } export function createReplacementString( wordString: string, event: Event, customNumberHandler?: ((prefix: string, number: number, suffix: string) => string), stepping?: {step?: number, range?: {min?: number, max?: number}}): string|null { let prefix; let suffix; let number; let replacementString: string|null = null; let matches = /(.*#)([\da-fA-F]+)(.*)/.exec(wordString); if (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?.length) { prefix = matches[1]; suffix = matches[3]; number = modifiedFloatNumber(parseFloat(matches[2]), event, stepping?.step, stepping?.range); if (number !== null) { replacementString = customNumberHandler ? customNumberHandler(prefix, number, suffix) : prefix + number + suffix; } } } return replacementString; } export function isElementValueModification(event: Event): boolean { if (event instanceof MouseEvent) { const {type} = event; return type === 'mousemove' || type === 'wheel'; } if (event instanceof KeyboardEvent) { const {key} = event; return key === 'ArrowUp' || key === 'ArrowDown' || key === 'PageUp' || key === 'PageDown'; } return false; } export function handleElementValueModifications( event: Event, element: Element, finishHandler?: ((arg0: string, arg1: string) => void), suggestionHandler?: ((arg0: string) => boolean), customNumberHandler?: ((arg0: string, arg1: number, arg2: string) => string)): boolean { if (!isElementValueModification(event)) { return false; } void VisualLogging.logKeyDown(event.currentTarget, event, 'element-value-modification'); const selection = element.getComponentSelection(); if (!selection?.rangeCount) { return false; } const selectionRange = selection.getRangeAt(0); if (!selectionRange.commonAncestorContainer.isSelfOrDescendant(element)) { return false; } const originalValue = element.textContent; const wordRange = rangeOfWord(selectionRange.startContainer, selectionRange.startOffset, StyleValueDelimiters, element); const wordString = wordRange.toString(); if (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; } export function openLinkExternallyLabel(): string { return i18nString(UIStrings.openInNewTab); } export function copyLinkAddressLabel(): string { return i18nString(UIStrings.copyLinkAddress); } export function copyFileNameLabel(): string { return i18nString(UIStrings.copyFileName); } export function anotherProfilerActiveLabel(): string { return i18nString(UIStrings.anotherProfilerIsAlreadyActive); } export function asyncStackTraceLabel( description: string|undefined, previousCallFrames: Array<{functionName: string}>): string { if (description) { if (description === 'Promise.resolve') { return i18nString(UIStrings.promiseResolvedAsync); } if (description === 'Promise.reject') { return i18nString(UIStrings.promiseRejectedAsync); } if (description === 'await' && previousCallFrames.length !== 0) { const lastPreviousFrame = previousCallFrames[previousCallFrames.length - 1]; const lastPreviousFrameName = beautifyFunctionName(lastPreviousFrame.functionName); description = `await in ${lastPreviousFrameName}`; } return description; } return i18nString(UIStrings.asyncCall); } export function addPlatformClass(element: HTMLElement): void { element.classList.add('platform-' + Host.Platform.platform()); } export function installComponentRootStyles(element: HTMLElement): void { appendStyle(element, inspectorCommonStyles); appendStyle(element, Buttons.textButtonStyles); // Detect overlay scrollbar enable by checking for nonzero scrollbar width. if (!Host.Platform.isMac() && measuredScrollbarWidth(element.ownerDocument) === 0) { element.classList.add('overlay-scrollbar-enabled'); } } function windowFocused(document: Document, event: Event): void { if (event.target instanceof Window && event.target.document.nodeType === Node.DOCUMENT_NODE) { document.body.classList.remove('inactive'); } } function windowBlurred(document: Document, event: Event): void { if (event.target instanceof Window && event.target.document.nodeType === Node.DOCUMENT_NODE) { document.body.classList.add('inactive'); } } export class ElementFocusRestorer { private element: HTMLElement|null; private previous: HTMLElement|null; constructor(element: Element) { this.element = (element as HTMLElement | null); this.previous = (deepActiveElement(element.ownerDocument) as HTMLElement | null); (element as HTMLElement).focus(); } restore(): void { if (!this.element) { return; } if (this.element.hasFocus() && this.previous) { this.previous.focus(); } this.previous = null; this.element = null; } } export function runCSSAnimationOnce(element: Element, className: string): void { function animationEndCallback(): void { element.classList.remove(className); element.removeEventListener('webkitAnimationEnd', animationEndCallback, false); element.removeEventListener('animationcancel', animationEndCallback, false); } if (element.classList.contains(className)) { element.classList.remove(className); } element.addEventListener('webkitAnimationEnd', animationEndCallback, false); element.addEventListener('animationcancel', animationEndCallback, false); element.classList.add(className); } export function measurePreferredSize(element: Element, containerElement?: Element|null): Geometry.Size { 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 Geometry.Size(result.width, result.height); } class InvokeOnceHandlers { private handlers: Map<object, Set<(...args: any[]) => void>>|null; private readonly autoInvoke: boolean; constructor(autoInvoke: boolean) { this.handlers = null; this.autoInvoke = autoInvoke; } add(object: Object, method: () => void): void { 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(): void { if (this.handlers) { requestAnimationFrame(this.invoke.bind(this)); } } private invoke(): void { const handlers = this.handlers; this.handlers = null; if (handlers) { for (const [object, methods] of handlers) { for (const method of methods) { method.call(object); } } } } } let coalescingLevel = 0; let postUpdateHandlers: InvokeOnceHandlers|null = null; export function startBatchUpdate(): void { if (!coalescingLevel++) { postUpdateHandlers = new InvokeOnceHandlers(false); } } export function endBatchUpdate(): void { if (--coalescingLevel) { return; } if (postUpdateHandlers) { postUpdateHandlers.scheduleInvoke(); postUpdateHandlers = null; } } export function animateFunction( window: Window, func: (...args: any[]) => void, params: Array<{ from: number, to: number, }>, duration: number, animationComplete?: (() => void)): () => void { const start = window.performance.now(); let raf = window.requestAnimationFrame(animationStep); function animationStep(timestamp: number): void { 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 { private readonly element: Element; private readonly callback: (arg0: Event) => void; private readonly editKey: (arg0: KeyboardEvent) => boolean; private longClickData!: { mouseUp: (arg0: Event) => void, mouseDown: (arg0: Event) => void, reset: () => void, }|undefined; private longClickInterval!: number|undefined; constructor( element: Element, callback: (arg0: Event) => void, isEditKeyFunc: (arg0: KeyboardEvent) => boolean = (event): boolean => Platform.KeyboardUtilities.isEnterOrSpaceKey(event)) { this.element = element; this.callback = callback; this.editKey = isEditKeyFunc; this.enable(); } reset(): void { if (this.longClickInterval) { clearInterval(this.longClickInterval); delete this.longClickInterval; } } private enable(): void { 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('pointerdown', boundMouseDown, false); this.element.addEventListener('pointerout', boundReset, false); this.element.addEventListener('pointerup', boundMouseUp, false); this.element.addEventListener('click', boundReset, true); this.longClickData = {mouseUp: boundMouseUp, mouseDown: boundMouseDown, reset: boundReset}; function keyDown(this: LongClickController, e: Event): void { if (this.editKey(e as KeyboardEvent)) { const callback = this.callback; this.longClickInterval = window.setTimeout(callback.bind(null, e), LongClickController.TIME_MS); } } function keyUp(this: LongClickController, e: Event): void { if (this.editKey(e as KeyboardEvent)) { this.reset(); } } function mouseDown(this: LongClickController, e: Event): void { if ((e as MouseEvent).which !== 1) { return; } const callback = this.callback; this.longClickInterval = window.setTimeout(callback.bind(null, e), LongClickController.TIME_MS); } function mouseUp(this: LongClickController, e: Event): void { if ((e as MouseEvent).which !== 1) { return; } this.reset(); } } dispose(): void { if (!this.longClickData) { return; } this.element.removeEventListener('pointerdown', this.longClickData.mouseDown, false); this.element.removeEventListener('pointerout', this.longClickData.reset, false); this.element.removeEventListener('pointerup', this.longClickData.mouseUp, false); this.element.addEventListener('click', this.longClickData.reset, true); delete this.longClickData; } static readonly TIME_MS = 200; } export function initializeUIUtils(document: Document): void { 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); const body = (document.body as Element); GlassPane.setContainer(body); } export function beautifyFunctionName(name: string): string { return name || i18nString(UIStrings.anonymous); } export const createTextChild = (element: Element|DocumentFragment, text: string): Text => { const textNode = element.ownerDocument.createTextNode(text); element.appendChild(textNode); return textNode; }; export const createTextChildren = (element: Element|DocumentFragment, ...childrenText: string[]): void => { for (const child of childrenText) { createTextChild(element, child); } }; export function createTextButton(text: string, clickHandler?: ((arg0: Event) => void), opts?: { className?: string, jslogContext?: string, variant?: Buttons.Button.Variant, title?: string, icon?: string, }): Buttons.Button.Button { const button = new Buttons.Button.Button(); if (opts?.className) { button.className = opts.className; } button.textContent = text; button.iconName = opts?.icon; button.variant = opts?.variant ? opts.variant : Buttons.Button.Variant.OUTLINED; if (clickHandler) { button.addEventListener('click', clickHandler); button.addEventListener('keydown', (event: KeyboardEvent): void => { if (event.key === 'Enter' || event.key === 'Space') { // Make sure we don't propagate 'Enter' or 'Space' key events to parents, // so that these get turned into 'click' events properly. event.stopImmediatePropagation(); } }); } if (opts?.jslogContext) { button.setAttribute('jslog', `${VisualLogging.action().track({click: true}).context(opts.jslogContext)}`); } if (opts?.title) { button.setAttribute('title', opts.title); } button.type = 'button'; return button; } export function createInput(className?: string, type?: string, jslogContext?: string): HTMLInputElement { const element = document.createElement('input'); if (className) { element.className = className; } element.spellcheck = false; element.classList.add('harmony-input'); if (type) { element.type = type; } if (jslogContext) { element.setAttribute( 'jslog', `${VisualLogging.textField().track({keydown: 'Enter', change: true}).context(jslogContext)}`); } return element; } export function createHistoryInput(type = 'search', className?: string): HTMLInputElement { const history = ['']; let historyPosition = 0; const historyInput = document.createElement('input'); historyInput.type = type; if (className) { historyInput.className = className; } historyInput.addEventListener('input', onInput, false); historyInput.addEventListener('keydown', onKeydown, false); return historyInput; function onInput(_event: Event): void { if (history.length === historyPosition + 1) { history[historyPosition] = historyInput.value; } } function onKeydown(event: KeyboardEvent): void { if (event.keyCode === Keys.Up.code) { historyPosition = Math.max(historyPosition - 1, 0); historyInput.value = history[historyPosition]; historyInput.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); event.consume(true); } else if (event.keyCode === Keys.Down.code) { historyPosition = Math.min(historyPosition + 1, history.length - 1); historyInput.value = history[historyPosition]; historyInput.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); event.consume(true); } else if (event.keyCode === Keys.Enter.code) { if (history.length > 1 && history[history.length - 2] === historyInput.value) { return; } history[history.length - 1] = historyInput.value; historyPosition = history.length - 1; history.push(''); } } } export function createSelect( name: string, options: string[]|Array<Map<string, string[]>>|Set<string>): HTMLSelectElement { const select = document.createElement('select'); ARIAUtils.setLabel(select, name); for (const option of options) { if (option instanceof Map) { for (const [key, value] of option) { const optGroup = select.createChild('optgroup'); optGroup.label = key; for (const child of value) { if (typeof child === 'string') { optGroup.appendChild(createOption(child, child, Platform.StringUtilities.toKebabCase(child))); } } } } else if (typeof option === 'string') { select.add(createOption(option, option, Platform.StringUtilities.toKebabCase(option))); } } return select; } export function createOption(title: string, value?: string, jslogContext?: string): HTMLOptionElement { const result = new Option(title, value || title); if (jslogContext) { result.setAttribute('jslog', `${VisualLogging.item(jslogContext).track({click: true})}`); } return result; } export function createLabel(title: string, className?: string, associatedControl?: Element): Element { const element = document.createElement('label'); if (className) { element.className = className; } element.textContent = title; if (associatedControl) { ARIAUtils.bindLabelToControl(element, associatedControl); } return element; } export function createIconLabel( options: {iconName: string, title?: string, color?: string, width?: '14px'|'20px', height?: '14px'|'20px'}): DevToolsIconLabel { const element = document.createElement('dt-icon-label'); if (options.title) { element.createChild('span').textContent = options.title; } element.data = { iconName: options.iconName, color: options.color ?? 'var(--icon-default)', width: options.width ?? '14px', height: options.height ?? '14px', }; return element; } /** * Creates a radio button, which is comprised of a `<label>` and an `<input type="radio">` element. * * The returned pair contains the `label` element and and the `radio` input element. The latter is * a child of the `label`, and therefore no association via `for` attribute is necessary to make * the radio button accessible. * * The element is automatically styled correctly, as long as the core styles (in particular * `inspectorCommon.css` is injected into the current document / shadow root). The lit * equivalent of calling this method is: * * ```js * const jslog = VisualLogging.toggle().track({change: true}).context(jslogContext); * html`<label><input type="radio" name=${name} jslog=${jslog}>${title}</label>` * ``` * * @param name the name of the radio group. * @param title the label text for the radio button. * @param jslogContext the context string for the `jslog` attribute. * @returns the pair of `HTMLLabelElement` and `HTMLInputElement`. * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio */ export function createRadioButton( name: string, title: string, jslogContext: string): {label: HTMLLabelElement, radio: HTMLInputElement} { const label = document.createElement('label'); const radio = label.createChild('input'); radio.type = 'radio'; radio.name = name; radio.setAttribute('jslog', `${VisualLogging.toggle().track({change: true}).context(jslogContext)}`); createTextChild(label, title); return {label, radio}; } /** * Creates an `<input type="range">` element with the specified parameters (a slider) * and a `step` of 1 (the default for the element). * * The element is automatically styled correctly, as long as the core styles (in particular * `inspectorCommon.css` is injected into the current document / shadow root). The lit * equivalent of calling this method is: * * ```js * html`<input type="range" min=${min} max=${max} tabindex=${tabIndex}>` * ``` * * @param min the minimum allowed value. * @param max the maximum allowed value. * @param tabIndex the value for the `tabindex` attribute. * @returns the newly created `HTMLInputElement` for the slider. * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range */ export function createSlider(min: number, max: number, tabIndex: number): HTMLInputElement { const element = document.createElement('input'); element.type = 'range'; element.min = String(min); element.max = String(max); element.tabIndex = tabIndex; return element; } export function setTitle(element: HTMLElement, title: string): void { ARIAUtils.setLabel(element, title); Tooltip.install(element, title); } export class CheckboxLabel extends HTMLElement { static readonly observedAttributes = ['checked', 'disabled', 'indeterminate', 'name', 'title', 'aria-label']; readonly #shadowRoot!: DocumentFragment; #checkboxElement!: HTMLInputElement; #textElement!: HTMLElement; constructor() { super(); CheckboxLabel.lastId = CheckboxLabel.lastId + 1; const id = 'ui-checkbox-label' + CheckboxLabel.lastId; this.#shadowRoot = createShadowRootWithCoreStyles(this, {cssFile: checkboxTextLabelStyles, delegatesFocus: true}); this.#checkboxElement = this.#shadowRoot.createChild('input'); this.#checkboxElement.type = 'checkbox'; this.#checkboxElement.setAttribute('id', id); // Change event is not composable, so it doesn't bubble up through the shadow root. this.#checkboxElement.addEventListener('change', () => this.dispatchEvent(new Event('change'))); this.#textElement = this.#shadowRoot.createChild('label', 'devtools-checkbox-text'); this.#textElement.setAttribute('for', id); // Click events are composable, so both label and checkbox bubble up through the shadow root. // However, clicking the label, also triggers the checkbox click, so we stop the label event // propagation here to avoid duplicate events. this.#textElement.addEventListener('click', e => e.stopPropagation()); this.#textElement.createChild('slot'); } static create( title?: Platform.UIString.LocalizedString, checked?: boolean, subtitle?: Platform.UIString.LocalizedString, jslogContext?: string, small?: boolean): CheckboxLabel { const element = document.createElement('devtools-checkbox'); element.#checkboxElement.checked = Boolean(checked); if (jslogContext) { element.#checkboxElement.setAttribute( 'jslog', `${VisualLogging.toggle().track({change: true}).context(jslogContext)}`); } if (title !== undefined) { element.#textElement.textContent = title; element.#checkboxElement.title = title; if (subtitle !== undefined) { element.#textElement.createChild('div', 'devtools-checkbox-subtitle').textContent = subtitle; } } element.#checkboxElement.classList.toggle('small', small); return element; } attributeChangedCallback(name: string, _oldValue: string|null, newValue: string|null): void { if (name === 'checked') { this.#checkboxElement.checked = newValue !== null; } else if (name === 'disabled') { this.#checkboxElement.disabled = newValue !== null; } else if (name === 'indeterminate') { this.#checkboxElement.indeterminate = newValue !== null; } else if (name === 'name') { this.#checkboxElement.name = newValue ?? ''; } else if (name === 'title') { this.#checkboxElement.title = newValue ?? ''; this.#textElement.title = newValue ?? ''; } else if (name === 'aria-label') { this.#checkboxElement.ariaLabel = newValue; } } getLabelText(): string|null { return this.#textElement.textContent; } setLabelText(content: string): void { this.#textElement.textContent = content; } override get ariaLabel(): string|null { return this.#checkboxElement.ariaLabel; } override set ariaLabel(ariaLabel: string) { this.setAttribute('aria-label', ariaLabel); } get checked(): boolean { return this.#checkboxElement.checked; } set checked(checked: boolean) { this.toggleAttribute('checked', checked); } set disabled(disabled: boolean) { this.toggleAttribute('disabled', disabled); } get disabled(): boolean { return this.#checkboxElement.disabled; } set indeterminate(indeterminate: boolean) { this.toggleAttribute('indeterminate', indeterminate); } get indeterminate(): boolean { return this.#checkboxElement.indeterminate; } override set title(title: string) { this.setAttribute('title', title); } override get title(): string { return this.#checkboxElement.title; } set name(name: string) { this.setAttribute('name', name); } get name(): string { return this.#checkboxElement.name; } override click(): void { this.#checkboxElement.click(); } /** Only to be used when the checkbox label is 'generated' (a regex, a className, etc). Most checkboxes should be create()'d with UIStrings */ static createWithStringLiteral(title?: string, checked?: boolean, jslogContext?: string, small?: boolean): CheckboxLabel { const stringLiteral = title as Platform.UIString.LocalizedString; return CheckboxLabel.create(stringLiteral, checked, undefined, jslogContext, small); } private static lastId = 0; } customElements.define('devtools-checkbox', CheckboxLabel); export class DevToolsIconLabel extends HTMLElement { readonly #icon: Icon; constructor() { super(); const root = createShadowRootWithCoreStyles(this); this.#icon = new Icon(); this.#icon.style.setProperty('margin-right', '4px'); this.#icon.style.setProperty('vertical-align', 'baseline'); root.appendChild(this.#icon); root.createChild('slot'); } set data(data: IconData) { this.#icon.data = data; // TODO(crbug.com/1427397): Clean this up. This was necessary so `DevToolsIconLabel` can use Lit icon // while being backwards-compatible with the legacy Icon while working for both small and large icons. if (data.height === '14px') { this.#icon.style.setProperty('margin-bottom', '-2px'); } else if (data.height === '20px') { this.#icon.style.setProperty('margin-bottom', '2px'); } } } // eslint-disable-next-line @devtools/enforce-custom-element-prefix customElements.define('dt-icon-label', DevToolsIconLabel); export class DevToolsSmallBubble extends HTMLElement { private textElement: Element; constructor() { super(); const root = createShadowRootWithCoreStyles(this, {cssFile: smallBubbleStyles}); this.textElement = root.createChild('div'); this.textElement.className = 'info'; this.textElement.createChild('slot'); } set type(type: string) { this.textElement.className = type; } } // eslint-disable-next-line @devtools/enforce-custom-element-prefix customElements.define('dt-small-bubble', DevToolsSmallBubble); export class DevToolsCloseButton extends HTMLElement { #button: Buttons.Button.Button; constructor() { super(); const root = createShadowRootWithCoreStyles(this); this.#button = new Buttons.Button.Button(); this.#button.data = {variant: Buttons.Button.Variant.ICON, iconName: 'cross'}; this.#button.classList.add('close-button'); this.#button.setAttribute('jslog', `${VisualLogging.close().track({click: true})}`); Tooltip.install(this.#button, i18nString(UIStrings.close)); ARIAUtils.setLabel(this.#button, i18nString(UIStrings.close)); root.appendChild(this.#button); } setAccessibleName(name: string): void { ARIAUtils.setLabel(this.#button, name); } setSize(size: Buttons.Button.Size): void { this.#button.size = size; } setTabbable(tabbable: boolean): void { if (tabbable) { this.#button.tabIndex = 0; } else { this.#button.tabIndex = -1; } } override focus(): void { this.#button.focus(); } } // eslint-disable-next-line @devtools/enforce-custom-element-prefix customElements.define('dt-close-button', DevToolsCloseButton); export function bindInput( input: HTMLInputElement, apply: (arg0: string) => void, validate: (arg0: string) => boolean, numeric: boolean, modifierMultiplier?: number): (arg0: string) => void { input.addEventListener('change', onChange, false); input.addEventListener('input', onInput, false); input.addEventListener('keydown', onKeyDown, false); input.addEventListener('focus', input.select.bind(input), false); function onInput(): void { input.classList.toggle('error-input', !validate(input.value)); } function onChange(): void { const valid = validate(input.value); input.classList.toggle('error-input', !valid); if (valid) { apply(input.value); } } function onKeyDown(event: KeyboardEvent): void { if (event.key === 'Enter') { const valid = validate(input.value); if (valid) { apply(input.value); } event.preventDefault(); return; } if (!numeric) { return; } const value = modifiedFloatNumber(parseFloat(input.value), event, modifierMultiplier); if (value === null) { return; } const stringValue = String(value); const valid = validate(stringValue); if (valid) { setValue(stringValue); } event.preventDefault(); } function setValue(value: string): void { if (value === input.value) { return; } const valid = validate(value); input.classList.toggle('error-input', !valid); input.value = value; } return setValue; } export function trimText( context: CanvasRenderingContext2D, text: string, maxWidth: number, trimFunction: (arg0: string, arg1: number) => string): string { const maxLength = 200; if (maxWidth <= 10) { return ''; } if (text.length > maxLength) { text = trimFunction(text, maxLength); } const textWidth = measureTextWidth(context, text); if (textWidth <= maxWidth) { return text; } let l = 0; let r: number = text.length; let lv = 0; let rv: number = textWidth; while (l < r && lv !== rv && lv !== maxWidth) { const m = Math.ceil(l + (r - l) * (maxWidth - lv) / (rv - lv)); const mv = measureTextWidth(context, trimFunction(text, m)); if (mv <= maxWidth) { l = m; lv = mv; } else { r = m - 1; rv = mv; } } text = trimFunction(text, l); return text !== '…' ? text : ''; } export function trimTextMiddle(context: CanvasRenderingContext2D, text: string, maxWidth: number): string { return trimText(context, text, maxWidth, (text, width) => Platform.StringUtilities.trimMiddle(text, width)); } export function trimTextEnd(context: CanvasRenderingContext2D, text: string, maxWidth: number): string { return trimText(context, text, maxWidth, (text, width) => Platform.StringUtilities.trimEndWithMaxLength(text, width)); } export function measureTextWidth(context: CanvasRenderingContext2D, text: string): number { const maxCacheableLength = 200; if (text.length > maxCacheableLength) { return context.measureText(text).width; } if (!measureTextWidthCache) { measureTextWidthCache = new Map(); } const font = context.font; let textWidths = measureTextWidthCache.get(font); if (!textWidths) { textWidths = new Map(); measureTextWidthCache.set(font, textWidths); } let width = textWidths.get(text); if (!width) { width = context.measureText(text).width; textWidths.set(text, width); } return width; } let measureTextWidthCache: Map<string, Map<string, number>>|null = null; export function loadImage(url: string): Promise<HTMLImageElement|null> {