chrome-devtools-frontend
Version:
Chrome DevTools UI
700 lines (605 loc) • 21.9 kB
text/typescript
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* Copyright (C) 2008 Apple Inc. All Rights Reserved.
* Copyright (C) 2011 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:
* 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.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. ``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 INC. 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 DOMExtension from '../../core/dom_extension/dom_extension.js';
import * as Platform from '../../core/platform/platform.js';
import * as Helpers from '../components/helpers/helpers.js';
import {Constraints, Size} from './Geometry.js';
import * as ThemeSupport from './theme_support/theme_support.js';
import * as Utils from './utils/utils.js';
import {XWidget} from './XWidget.js';
export class WidgetElement extends HTMLDivElement {
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/naming-convention, rulesdir/no_underscored_properties
override __widget!: Widget|null;
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/naming-convention, rulesdir/no_underscored_properties
__widgetCounter!: number|null;
constructor() {
super();
}
}
export class Widget {
element!: WidgetElement;
contentElement: HTMLDivElement;
private shadowRoot: ShadowRoot|undefined;
private readonly isWebComponent: boolean|undefined;
protected visibleInternal: boolean;
private isRoot: boolean;
private isShowingInternal: boolean;
private readonly childrenInternal: Widget[];
private hideOnDetach: boolean;
private notificationDepth: number;
private invalidationsSuspended: number;
defaultFocusedChild: Widget|null;
private parentWidgetInternal: Widget|null;
private registeredCSSFiles: boolean;
private defaultFocusedElement?: Element|null;
private cachedConstraints?: Constraints;
private constraintsInternal?: Constraints;
private invalidationsRequested?: boolean;
private externallyManaged?: boolean;
constructor(isWebComponent?: boolean, delegatesFocus?: boolean) {
this.contentElement = document.createElement('div');
this.contentElement.classList.add('widget');
if (isWebComponent) {
this.element = (document.createElement('div') as WidgetElement);
this.element.classList.add('vbox');
this.element.classList.add('flex-auto');
this.shadowRoot = Utils.createShadowRootWithCoreStyles(this.element, {
cssFile: undefined,
delegatesFocus,
});
this.shadowRoot.appendChild(this.contentElement);
} else {
this.element = (this.contentElement as WidgetElement);
}
this.isWebComponent = isWebComponent;
this.element.__widget = this;
this.visibleInternal = false;
this.isRoot = false;
this.isShowingInternal = false;
this.childrenInternal = [];
this.hideOnDetach = false;
this.notificationDepth = 0;
this.invalidationsSuspended = 0;
this.defaultFocusedChild = null;
this.parentWidgetInternal = null;
this.registeredCSSFiles = false;
}
private static incrementWidgetCounter(parentElement: WidgetElement, childElement: WidgetElement): void {
const count = (childElement.__widgetCounter || 0) + (childElement.__widget ? 1 : 0);
if (!count) {
return;
}
let currentElement: (WidgetElement|null)|WidgetElement = parentElement;
while (currentElement) {
currentElement.__widgetCounter = (currentElement.__widgetCounter || 0) + count;
currentElement = parentWidgetElementOrShadowHost(currentElement);
}
}
private static decrementWidgetCounter(parentElement: WidgetElement, childElement: WidgetElement): void {
const count = (childElement.__widgetCounter || 0) + (childElement.__widget ? 1 : 0);
if (!count) {
return;
}
let currentElement: (WidgetElement|null)|WidgetElement = parentElement;
while (currentElement) {
if (currentElement.__widgetCounter) {
currentElement.__widgetCounter -= count;
}
currentElement = parentWidgetElementOrShadowHost(currentElement);
}
}
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/naming-convention
private static assert(condition: any, message: string): void {
if (!condition) {
throw new Error(message);
}
}
markAsRoot(): void {
Widget.assert(!this.element.parentElement, 'Attempt to mark as root attached node');
this.isRoot = true;
}
parentWidget(): Widget|null {
return this.parentWidgetInternal;
}
children(): Widget[] {
return this.childrenInternal;
}
childWasDetached(_widget: Widget): void {
}
isShowing(): boolean {
return this.isShowingInternal;
}
shouldHideOnDetach(): boolean {
if (!this.element.parentElement) {
return false;
}
if (this.hideOnDetach) {
return true;
}
for (const child of this.childrenInternal) {
if (child.shouldHideOnDetach()) {
return true;
}
}
return false;
}
setHideOnDetach(): void {
this.hideOnDetach = true;
}
private inNotification(): boolean {
return Boolean(this.notificationDepth) ||
Boolean(this.parentWidgetInternal && this.parentWidgetInternal.inNotification());
}
private parentIsShowing(): boolean {
if (this.isRoot) {
return true;
}
return this.parentWidgetInternal !== null && this.parentWidgetInternal.isShowing();
}
protected callOnVisibleChildren(method: (this: Widget) => void): void {
const copy = this.childrenInternal.slice();
for (let i = 0; i < copy.length; ++i) {
if (copy[i].parentWidgetInternal === this && copy[i].visibleInternal) {
method.call(copy[i]);
}
}
}
private processWillShow(): void {
this.callOnVisibleChildren(this.processWillShow);
this.isShowingInternal = true;
}
private processWasShown(): void {
if (this.inNotification()) {
return;
}
this.restoreScrollPositions();
this.notify(this.wasShown);
this.callOnVisibleChildren(this.processWasShown);
}
private processWillHide(): void {
if (this.inNotification()) {
return;
}
this.storeScrollPositions();
this.callOnVisibleChildren(this.processWillHide);
this.notify(this.willHide);
this.isShowingInternal = false;
}
private processWasHidden(): void {
this.callOnVisibleChildren(this.processWasHidden);
}
private processOnResize(): void {
if (this.inNotification()) {
return;
}
if (!this.isShowing()) {
return;
}
this.notify(this.onResize);
this.callOnVisibleChildren(this.processOnResize);
}
private notify(notification: (this: Widget) => void): void {
++this.notificationDepth;
try {
notification.call(this);
} finally {
--this.notificationDepth;
}
}
wasShown(): void {
}
willHide(): void {
}
onResize(): void {
}
onLayout(): void {
}
async ownerViewDisposed(): Promise<void> {
}
show(parentElement: Element, insertBefore?: Node|null): void {
Widget.assert(parentElement, 'Attempt to attach widget with no parent element');
if (!this.isRoot) {
// Update widget hierarchy.
let currentParent: (WidgetElement|null) = (parentElement as WidgetElement | null);
while (currentParent && !currentParent.__widget) {
currentParent = parentWidgetElementOrShadowHost(currentParent);
}
if (!currentParent || !currentParent.__widget) {
throw new Error('Attempt to attach widget to orphan node');
}
this.attach(currentParent.__widget);
}
this.showWidgetInternal((parentElement as WidgetElement), insertBefore);
}
private attach(parentWidget: Widget): void {
if (parentWidget === this.parentWidgetInternal) {
return;
}
if (this.parentWidgetInternal) {
this.detach();
}
this.parentWidgetInternal = parentWidget;
this.parentWidgetInternal.childrenInternal.push(this);
this.isRoot = false;
}
showWidget(): void {
if (this.visibleInternal) {
return;
}
if (!this.element.parentElement) {
throw new Error('Attempt to show widget that is not hidden using hideWidget().');
}
this.showWidgetInternal((this.element.parentElement as WidgetElement), this.element.nextSibling);
}
private showWidgetInternal(parentElement: WidgetElement, insertBefore?: Node|null): void {
let currentParent: (WidgetElement|null)|WidgetElement = parentElement;
while (currentParent && !currentParent.__widget) {
currentParent = parentWidgetElementOrShadowHost(currentParent);
}
if (this.isRoot) {
Widget.assert(!currentParent, 'Attempt to show root widget under another widget');
} else {
Widget.assert(
currentParent && currentParent.__widget === this.parentWidgetInternal,
'Attempt to show under node belonging to alien widget');
}
const wasVisible = this.visibleInternal;
if (wasVisible && this.element.parentElement === parentElement) {
return;
}
this.visibleInternal = true;
if (!wasVisible && this.parentIsShowing()) {
this.processWillShow();
}
this.element.classList.remove('hidden');
// Reparent
if (this.element.parentElement !== parentElement) {
if (!this.externallyManaged) {
Widget.incrementWidgetCounter(parentElement, this.element);
}
if (insertBefore) {
DOMExtension.DOMExtension.originalInsertBefore.call(parentElement, this.element, insertBefore);
} else {
DOMExtension.DOMExtension.originalAppendChild.call(parentElement, this.element);
}
}
if (!wasVisible && this.parentIsShowing()) {
this.processWasShown();
}
if (this.parentWidgetInternal && this.hasNonZeroConstraints()) {
this.parentWidgetInternal.invalidateConstraints();
} else {
this.processOnResize();
}
}
hideWidget(): void {
if (!this.visibleInternal) {
return;
}
this.hideWidgetInternal(false);
}
private hideWidgetInternal(removeFromDOM: boolean): void {
this.visibleInternal = false;
const parentElement = (this.element.parentElement as WidgetElement);
if (this.parentIsShowing()) {
this.processWillHide();
}
if (removeFromDOM) {
// Force legal removal
Widget.decrementWidgetCounter(parentElement, this.element);
DOMExtension.DOMExtension.originalRemoveChild.call(parentElement, this.element);
} else {
this.element.classList.add('hidden');
}
if (this.parentIsShowing()) {
this.processWasHidden();
}
if (this.parentWidgetInternal && this.hasNonZeroConstraints()) {
this.parentWidgetInternal.invalidateConstraints();
}
}
detach(overrideHideOnDetach?: boolean): void {
if (!this.parentWidgetInternal && !this.isRoot) {
return;
}
// hideOnDetach means that we should never remove element from dom - content
// has iframes and detaching it will hurt.
//
// overrideHideOnDetach will override hideOnDetach and the client takes
// responsibility for the consequences.
const removeFromDOM = overrideHideOnDetach || !this.shouldHideOnDetach();
if (this.visibleInternal) {
this.hideWidgetInternal(removeFromDOM);
} else if (removeFromDOM && this.element.parentElement) {
const parentElement = (this.element.parentElement as WidgetElement);
// Force kick out from DOM.
Widget.decrementWidgetCounter(parentElement, this.element);
DOMExtension.DOMExtension.originalRemoveChild.call(parentElement, this.element);
}
// Update widget hierarchy.
if (this.parentWidgetInternal) {
const childIndex = this.parentWidgetInternal.childrenInternal.indexOf(this);
Widget.assert(childIndex >= 0, 'Attempt to remove non-child widget');
this.parentWidgetInternal.childrenInternal.splice(childIndex, 1);
if (this.parentWidgetInternal.defaultFocusedChild === this) {
this.parentWidgetInternal.defaultFocusedChild = null;
}
this.parentWidgetInternal.childWasDetached(this);
this.parentWidgetInternal = null;
} else {
Widget.assert(this.isRoot, 'Removing non-root widget from DOM');
}
}
detachChildWidgets(): void {
const children = this.childrenInternal.slice();
for (let i = 0; i < children.length; ++i) {
children[i].detach();
}
}
elementsToRestoreScrollPositionsFor(): Element[] {
return [this.element];
}
storeScrollPositions(): void {
const elements = this.elementsToRestoreScrollPositionsFor();
for (const container of elements) {
storedScrollPositions.set(container, {scrollLeft: container.scrollLeft, scrollTop: container.scrollTop});
}
}
restoreScrollPositions(): void {
const elements = this.elementsToRestoreScrollPositionsFor();
for (const container of elements) {
const storedPositions = storedScrollPositions.get(container);
if (storedPositions) {
container.scrollLeft = storedPositions.scrollLeft;
container.scrollTop = storedPositions.scrollTop;
}
}
}
doResize(): void {
if (!this.isShowing()) {
return;
}
// No matter what notification we are in, dispatching onResize is not needed.
if (!this.inNotification()) {
this.callOnVisibleChildren(this.processOnResize);
}
}
doLayout(): void {
if (!this.isShowing()) {
return;
}
this.notify(this.onLayout);
this.doResize();
}
registerRequiredCSS(cssFile: {cssContent: string}): void {
if (this.isWebComponent) {
ThemeSupport.ThemeSupport.instance().appendStyle((this.shadowRoot as DocumentFragment), cssFile);
} else {
ThemeSupport.ThemeSupport.instance().appendStyle(this.element, cssFile);
}
}
registerCSSFiles(cssFiles: CSSStyleSheet[]): void {
let root: ShadowRoot|Document;
if (this.isWebComponent && this.shadowRoot !== undefined) {
root = this.shadowRoot;
} else {
root = Helpers.GetRootNode.getRootNode(this.contentElement);
}
root.adoptedStyleSheets = root.adoptedStyleSheets.concat(cssFiles);
this.registeredCSSFiles = true;
}
printWidgetHierarchy(): void {
const lines: string[] = [];
this.collectWidgetHierarchy('', lines);
console.log(lines.join('\n')); // eslint-disable-line no-console
}
private collectWidgetHierarchy(prefix: string, lines: string[]): void {
lines.push(prefix + '[' + this.element.className + ']' + (this.childrenInternal.length ? ' {' : ''));
for (let i = 0; i < this.childrenInternal.length; ++i) {
this.childrenInternal[i].collectWidgetHierarchy(prefix + ' ', lines);
}
if (this.childrenInternal.length) {
lines.push(prefix + '}');
}
}
setDefaultFocusedElement(element: Element|null): void {
this.defaultFocusedElement = element;
}
setDefaultFocusedChild(child: Widget): void {
Widget.assert(child.parentWidgetInternal === this, 'Attempt to set non-child widget as default focused.');
this.defaultFocusedChild = child;
}
focus(): void {
if (!this.isShowing()) {
return;
}
const element = (this.defaultFocusedElement as HTMLElement | null);
if (element) {
if (!element.hasFocus()) {
element.focus();
}
return;
}
if (this.defaultFocusedChild && this.defaultFocusedChild.visibleInternal) {
this.defaultFocusedChild.focus();
} else {
for (const child of this.childrenInternal) {
if (child.visibleInternal) {
child.focus();
return;
}
}
let child = this.contentElement.traverseNextNode(this.contentElement);
while (child) {
if (child instanceof XWidget) {
child.focus();
return;
}
child = child.traverseNextNode(this.contentElement);
}
}
}
hasFocus(): boolean {
return this.element.hasFocus();
}
calculateConstraints(): Constraints {
return new Constraints();
}
constraints(): Constraints {
if (typeof this.constraintsInternal !== 'undefined') {
return this.constraintsInternal;
}
if (typeof this.cachedConstraints === 'undefined') {
this.cachedConstraints = this.calculateConstraints();
}
return this.cachedConstraints;
}
setMinimumAndPreferredSizes(width: number, height: number, preferredWidth: number, preferredHeight: number): void {
this.constraintsInternal = new Constraints(new Size(width, height), new Size(preferredWidth, preferredHeight));
this.invalidateConstraints();
}
setMinimumSize(width: number, height: number): void {
this.constraintsInternal = new Constraints(new Size(width, height));
this.invalidateConstraints();
}
private hasNonZeroConstraints(): boolean {
const constraints = this.constraints();
return Boolean(
constraints.minimum.width || constraints.minimum.height || constraints.preferred.width ||
constraints.preferred.height);
}
suspendInvalidations(): void {
++this.invalidationsSuspended;
}
resumeInvalidations(): void {
--this.invalidationsSuspended;
if (!this.invalidationsSuspended && this.invalidationsRequested) {
this.invalidateConstraints();
}
}
invalidateConstraints(): void {
if (this.invalidationsSuspended) {
this.invalidationsRequested = true;
return;
}
this.invalidationsRequested = false;
const cached = this.cachedConstraints;
delete this.cachedConstraints;
const actual = this.constraints();
if (!actual.isEqual(cached || null) && this.parentWidgetInternal) {
this.parentWidgetInternal.invalidateConstraints();
} else {
this.doLayout();
}
}
// Excludes the widget from being tracked by its parents/ancestors via
// widgetCounter because the widget is being handled by external code.
// Widgets marked as being externally managed are responsible for
// finishing out their own lifecycle (i.e. calling detach() before being
// removed from the DOM). This is e.g. used for CodeMirror.
//
// Also note that this must be called before the widget is shown so that
// so that its ancestor's widgetCounter is not incremented.
markAsExternallyManaged(): void {
Widget.assert(
!this.parentWidgetInternal, 'Attempt to mark widget as externally managed after insertion to the DOM');
this.externallyManaged = true;
}
}
const storedScrollPositions = new WeakMap<Element, {
scrollLeft: number,
scrollTop: number,
}>();
export class VBox extends Widget {
constructor(isWebComponent?: boolean, delegatesFocus?: boolean) {
super(isWebComponent, delegatesFocus);
this.contentElement.classList.add('vbox');
}
override calculateConstraints(): Constraints {
let constraints: Constraints = new Constraints();
function updateForChild(this: Widget): void {
const child = this.constraints();
constraints = constraints.widthToMax(child);
constraints = constraints.addHeight(child);
}
this.callOnVisibleChildren(updateForChild);
return constraints;
}
}
export class HBox extends Widget {
constructor(isWebComponent?: boolean) {
super(isWebComponent);
this.contentElement.classList.add('hbox');
}
override calculateConstraints(): Constraints {
let constraints: Constraints = new Constraints();
function updateForChild(this: Widget): void {
const child = this.constraints();
constraints = constraints.addWidth(child);
constraints = constraints.heightToMax(child);
}
this.callOnVisibleChildren(updateForChild);
return constraints;
}
}
export class VBoxWithResizeCallback extends VBox {
private readonly resizeCallback: () => void;
constructor(resizeCallback: () => void) {
super();
this.resizeCallback = resizeCallback;
}
override onResize(): void {
this.resizeCallback();
}
}
export class WidgetFocusRestorer {
private widget: Widget|null;
private previous: HTMLElement|null;
constructor(widget: Widget) {
this.widget = widget;
this.previous = (Platform.DOMUtilities.deepActiveElement(widget.element.ownerDocument) as HTMLElement | null);
widget.focus();
}
restore(): void {
if (!this.widget) {
return;
}
if (this.widget.hasFocus() && this.previous) {
this.previous.focus();
}
this.previous = null;
this.widget = null;
}
}
function parentWidgetElementOrShadowHost(element: WidgetElement): WidgetElement|null {
return element.parentElementOrShadowHost() as WidgetElement | null;
}