chrome-devtools-frontend
Version:
Chrome DevTools UI
734 lines (648 loc) • 30.3 kB
text/typescript
// Copyright 2023 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.
import * as Platform from '../../../core/platform/platform.js';
import * as WindowBoundsService from '../../../services/window_bounds/window_bounds.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as Coordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
import * as LitHtml from '../../../ui/lit-html/lit-html.js';
import dialogStyles from './dialog.css.js';
const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
const IS_DIALOG_SUPPORTED = 'HTMLDialogElement' in globalThis;
// Height in pixels of the dialog's connector. The connector is represented as
// as a diamond and the height corresponds to half the height of the diamond.
// (the visible height is only half of the diamond).
export const CONNECTOR_HEIGHT = 10;
const CONNECTOR_WIDTH = 2 * CONNECTOR_HEIGHT;
const DIALOG_ANIMATION_OFFSET = 20;
export const DIALOG_SIDE_PADDING = 5;
export const DIALOG_VERTICAL_PADDING = 3;
// If the content of the dialog cannot be completely shown because otherwise
// the dialog would overflow the window, the dialog's max width and height are
// set such that the dialog remains inside the visible bounds. In this cases
// some extra, determined by this constant, is added so that the dialog's borders
// remain clearly visible. This constant accounts for the padding of the dialog's
// content (20 px) and a 5px gap left on each extreme of the dialog from the viewport.
export const DIALOG_PADDING_FROM_WINDOW = 3 * CONNECTOR_HEIGHT;
interface DialogData {
/**
* Position or point the dialog is shown relative to.
* If the dialog instance will be shown as a modal, set
* this property to MODAL.
*/
origin: DialogOrigin;
position: DialogVerticalPosition;
/**
* Horizontal alignment of the dialg with respect to its origin.
* Center by default.
*/
horizontalAlignment: DialogHorizontalAlignment;
/**
* Whether the connector from the dialog to its origin is shown.
*/
showConnector: boolean;
/**
* Optional function used to the determine the x coordinate of the connector's
* end (tip of the triangle), relative to the viewport. If not defined, the x
* coordinate of the origin's center is used instead.
*/
getConnectorCustomXPosition: (() => number)|null;
/**
* Optional function called when the dialog is shown.
*/
dialogShownCallback: (() => unknown)|null;
/**
* Optional. Service that provides the window dimensions used for positioning the Dialog.
*/
windowBoundsService: WindowBoundsService.WindowBoundsService.WindowBoundsService;
/**
* Whether the dialog is closed when the 'Escape' key is pressed. When true, the event is
* propagation is stopped.
*/
closeOnESC: boolean;
/**
* Whether the dialog is closed when a scroll event is detected outside of the dialog's
* content. Defaults to true.
*/
closeOnScroll: boolean;
}
type DialogAnchor = HTMLElement|DOMRect|DOMPoint;
export const MODAL = 'MODAL';
export type DialogOrigin = DialogAnchor|null|(() => DialogAnchor)|typeof MODAL;
export class Dialog extends HTMLElement {
static readonly litTagName = LitHtml.literal`devtools-dialog`;
readonly #shadow = this.attachShadow({mode: 'open'});
readonly #renderBound = this.#render.bind(this);
readonly #forceDialogCloseInDevToolsBound = this.#forceDialogCloseInDevToolsMutation.bind(this);
readonly #handleScrollAttemptBound = this.#handleScrollAttempt.bind(this);
readonly #props: DialogData = {
origin: MODAL,
position: DialogVerticalPosition.BOTTOM,
horizontalAlignment: DialogHorizontalAlignment.CENTER,
showConnector: false,
getConnectorCustomXPosition: null,
dialogShownCallback: null,
windowBoundsService: WindowBoundsService.WindowBoundsService.WindowBoundsServiceImpl.instance(),
closeOnESC: true,
closeOnScroll: true,
};
#dialog: HTMLDialogElement|null = null;
#isPendingShowDialog = false;
#isPendingCloseDialog = false;
#hitArea = new DOMRect(0, 0, 0, 0);
#dialogClientRect = new DOMRect(0, 0, 0, 0);
#bestVerticalPositionInternal: DialogVerticalPosition|null = null;
#bestHorizontalAlignment: DialogHorizontalAlignment|null = null;
readonly #devtoolsMutationObserver = new MutationObserver(this.#forceDialogCloseInDevToolsBound);
readonly #dialogResizeObserver = new ResizeObserver(this.#updateDialogBounds.bind(this));
#devToolsBoundingElement = this.windowBoundsService.getDevToolsBoundingElement();
// We bind here because we have to listen to keydowns on the entire window,
// not on the Dialog element itself. This is because if the user has the
// dialog open, but their focus is elsewhere, and they hit ESC, we should
// still close the dialog.
#onKeyDownBound = this.#onKeyDown.bind(this);
get showConnector(): boolean {
return this.#props.showConnector;
}
set showConnector(showConnector: boolean) {
this.#props.showConnector = showConnector;
this.#onStateChange();
}
get origin(): DialogOrigin {
return this.#props.origin;
}
set origin(origin: DialogOrigin) {
this.#props.origin = origin;
this.#onStateChange();
}
get position(): DialogVerticalPosition {
return this.#props.position;
}
set position(position: DialogVerticalPosition) {
this.#props.position = position;
this.#onStateChange();
}
get horizontalAlignment(): DialogHorizontalAlignment {
return this.#props.horizontalAlignment;
}
set horizontalAlignment(alignment: DialogHorizontalAlignment) {
this.#props.horizontalAlignment = alignment;
this.#onStateChange();
}
get windowBoundsService(): WindowBoundsService.WindowBoundsService.WindowBoundsService {
return this.#props.windowBoundsService;
}
set windowBoundsService(windowBoundsService: WindowBoundsService.WindowBoundsService.WindowBoundsService) {
this.#props.windowBoundsService = windowBoundsService;
this.#devToolsBoundingElement = this.windowBoundsService.getDevToolsBoundingElement();
this.#onStateChange();
}
get bestVerticalPosition(): DialogVerticalPosition|null {
return this.#bestVerticalPositionInternal;
}
get bestHorizontalAlignment(): DialogHorizontalAlignment|null {
return this.#bestHorizontalAlignment;
}
get getConnectorCustomXPosition(): (() => number)|null {
return this.#props.getConnectorCustomXPosition;
}
set getConnectorCustomXPosition(connectorXPosition: (() => number)|null) {
this.#props.getConnectorCustomXPosition = connectorXPosition;
this.#onStateChange();
}
get dialogShownCallback(): (() => unknown)|null {
return this.#props.dialogShownCallback;
}
set dialogShownCallback(dialogShownCallback: (() => unknown)|null) {
this.#props.dialogShownCallback = dialogShownCallback;
this.#onStateChange();
}
set closeOnESC(closeOnESC: boolean) {
this.#props.closeOnESC = closeOnESC;
this.#onStateChange();
}
set closeOnScroll(closeOnScroll: boolean) {
this.#props.closeOnScroll = closeOnScroll;
this.#onStateChange();
}
#updateDialogBounds(): void {
this.#dialogClientRect = this.#getDialog().getBoundingClientRect();
}
#onStateChange(): void {
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#renderBound);
}
connectedCallback(): void {
this.#shadow.adoptedStyleSheets = [dialogStyles];
window.addEventListener('resize', this.#forceDialogCloseInDevToolsBound);
document.body.addEventListener('keydown', this.#onKeyDownBound);
this.#devtoolsMutationObserver.observe(this.#devToolsBoundingElement, {childList: true, subtree: true});
this.#devToolsBoundingElement.addEventListener('wheel', this.#handleScrollAttemptBound);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-padding', '0');
ComponentHelpers.SetCSSProperty.set(this, '--override-content-box-shadow', 'none');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-display', IS_DIALOG_SUPPORTED ? 'block' : 'none');
ComponentHelpers.SetCSSProperty.set(
this, '--override-dialog-content-border', `${CONNECTOR_HEIGHT}px solid transparent`);
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-padding', `${DIALOG_VERTICAL_PADDING}px ${DIALOG_SIDE_PADDING}px`);
}
disconnectedCallback(): void {
window.removeEventListener('resize', this.#forceDialogCloseInDevToolsBound);
document.body.removeEventListener('keydown', this.#onKeyDownBound);
this.#devToolsBoundingElement.removeEventListener('wheel', this.#handleScrollAttemptBound);
this.#devtoolsMutationObserver.disconnect();
this.#dialogResizeObserver.disconnect();
}
#getDialog(): HTMLDialogElement {
if (!this.#dialog) {
this.#dialog = this.#shadow.querySelector('dialog');
if (!this.#dialog) {
throw new Error('Dialog not found');
}
this.#dialogResizeObserver.observe(this.#dialog);
}
return this.#dialog;
}
getHitArea(): DOMRect {
return this.#hitArea;
}
async setDialogVisible(show: boolean): Promise<void> {
if (show) {
await this.#showDialog();
return;
}
this.#closeDialog();
}
async #handlePointerEvent(evt: MouseEvent|PointerEvent): Promise<void> {
evt.stopPropagation();
// If the user uses the keyboard to interact with an element within the
// dialog, it will trigger a pointer event (for example, the user might use
// their spacebar to "click" on a form input element). In that case the
// pointerType will be an empty string, rather than `mouse`, `pen` or
// `touch`. In this instance, we early return, because we only need to
// worry about clicks outside of the dialog. Once the dialog is open, the
// user can only use the keyboard to navigate within the dialog; so we
// don't have to concern ourselves with keyboard events that occur outside
// the dialog's bounds.
if (evt instanceof PointerEvent && evt.pointerType === '') {
return;
}
const eventWasInDialogContent = this.#mouseEventWasInDialogContent(evt);
const eventWasInHitArea = this.#mouseEventWasInHitArea(evt);
if (eventWasInDialogContent) {
return;
}
if (evt.type === 'pointermove') {
if (eventWasInHitArea) {
return;
}
this.dispatchEvent(new PointerLeftDialogEvent());
return;
}
this.dispatchEvent(new ClickOutsideDialogEvent());
}
#mouseEventWasInDialogContent(evt: MouseEvent): boolean {
const dialogBounds = this.#dialogClientRect;
const animationOffSetValue = this.bestVerticalPosition === DialogVerticalPosition.BOTTOM ?
DIALOG_ANIMATION_OFFSET :
-1 * DIALOG_ANIMATION_OFFSET;
const eventWasDialogContentX =
evt.pageX >= dialogBounds.left && evt.pageX <= dialogBounds.left + dialogBounds.width;
const eventWasDialogContentY = evt.pageY >= dialogBounds.top + animationOffSetValue &&
evt.pageY <= dialogBounds.top + dialogBounds.height + animationOffSetValue;
return eventWasDialogContentX && eventWasDialogContentY;
}
#mouseEventWasInHitArea(evt: MouseEvent): boolean {
const hitAreaBounds = this.#hitArea;
const eventWasInHitAreaX = evt.pageX >= hitAreaBounds.left && evt.pageX <= hitAreaBounds.left + hitAreaBounds.width;
const eventWasInHitAreaY = evt.pageY >= hitAreaBounds.top && evt.pageY <= hitAreaBounds.top + hitAreaBounds.height;
return eventWasInHitAreaX && eventWasInHitAreaY;
}
#getCoordinatesFromDialogOrigin(origin: DialogOrigin): AnchorBounds {
if (!origin || origin === MODAL) {
throw new Error('Dialog origin is null');
}
const anchor = origin instanceof Function ? origin() : origin;
if (anchor instanceof DOMPoint) {
return {top: anchor.y, bottom: anchor.y, left: anchor.x, right: anchor.x};
}
if (anchor instanceof HTMLElement) {
return anchor.getBoundingClientRect();
}
return anchor;
}
#getBestHorizontalAlignment(anchorBounds: AnchorBounds, devtoolsBounds: DOMRect): DialogHorizontalAlignment {
if (devtoolsBounds.right - anchorBounds.left > anchorBounds.right - devtoolsBounds.left) {
return DialogHorizontalAlignment.LEFT;
}
return DialogHorizontalAlignment.RIGHT;
}
#getBestVerticalPosition(originBounds: AnchorBounds, dialogHeight: number, windowheight: number):
DialogVerticalPosition {
const {bottom} = originBounds;
if (bottom + dialogHeight > windowheight) {
return DialogVerticalPosition.TOP;
}
return DialogVerticalPosition.BOTTOM;
}
#positionDialog(): void {
if (!this.#props.origin) {
return;
}
this.#isPendingShowDialog = true;
void coordinator.read(() => {
// Fixed elements are positioned relative to the window, regardless if
// DevTools is docked. As such, if DevTools is docked we must account for
// its offset relative to the window when positioning fixed elements.
// DevTools' effective offset can be determined using
// this.#devToolsBoundingElement.
const devtoolsBounds = this.#devToolsBoundingElement.getBoundingClientRect();
const devToolsWidth = devtoolsBounds.width;
const devToolsHeight = devtoolsBounds.height;
const devToolsLeft = devtoolsBounds.left;
const devToolsTop = devtoolsBounds.top;
const devToolsRight = devtoolsBounds.right;
if (this.#props.origin === MODAL) {
void coordinator.write(() => {
ComponentHelpers.SetCSSProperty.set(this, '--dialog-top', `${devToolsTop}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-left', `${devToolsLeft}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin', 'auto');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin-left', 'auto');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin-bottom', 'auto');
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-max-height', `${devToolsHeight - DIALOG_PADDING_FROM_WINDOW}px`);
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-max-width', `${devToolsWidth - DIALOG_PADDING_FROM_WINDOW}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-right', `${document.body.clientWidth - devToolsRight}px`);
});
return;
}
const anchor = this.#props.origin;
const absoluteAnchorBounds = this.#getCoordinatesFromDialogOrigin(anchor);
const {top: anchorTop, right: anchorRight, bottom: anchorBottom, left: anchorLeft} = absoluteAnchorBounds;
const originCenterX = (anchorLeft + anchorRight) / 2;
const hitAreaWidth = anchorRight - anchorLeft + CONNECTOR_HEIGHT;
const windowWidth = document.body.clientWidth;
const connectorFixedXValue =
this.#props.getConnectorCustomXPosition ? this.#props.getConnectorCustomXPosition() : originCenterX;
void coordinator.write(() => {
ComponentHelpers.SetCSSProperty.set(this, '--dialog-top', '0');
// Start by showing the dialog hidden to allow measuring its width.
const dialog = this.#getDialog();
dialog.style.visibility = 'hidden';
if (this.#isPendingShowDialog && !dialog.hasAttribute('open')) {
dialog.showModal();
this.setAttribute('open', '');
this.#isPendingShowDialog = false;
}
const {width: dialogWidth, height: dialogHeight} = dialog.getBoundingClientRect();
this.#bestHorizontalAlignment = this.#props.horizontalAlignment === DialogHorizontalAlignment.AUTO ?
this.#getBestHorizontalAlignment(absoluteAnchorBounds, devtoolsBounds) :
this.#props.horizontalAlignment;
this.#bestVerticalPositionInternal = this.#props.position === DialogVerticalPosition.AUTO ?
this.#getBestVerticalPosition(absoluteAnchorBounds, dialogHeight, devToolsHeight) :
this.#props.position;
if (this.#bestHorizontalAlignment === DialogHorizontalAlignment.AUTO ||
this.#bestVerticalPositionInternal === DialogVerticalPosition.AUTO) {
return;
}
this.#hitArea.height = anchorBottom - anchorTop + (CONNECTOR_HEIGHT * (this.showConnector ? 2 : 1));
this.#hitArea.width = hitAreaWidth;
let connectorRelativeXValue = 0;
// If the connector is to be shown, the dialog needs a minimum width such that it covers
// the connector's width.
ComponentHelpers.SetCSSProperty.set(
this, '--content-min-width',
`${connectorFixedXValue - anchorLeft + CONNECTOR_WIDTH + DIALOG_SIDE_PADDING * 2}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-left', 'auto');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-right', 'auto');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin', '0');
const offsetToCoverConnector = this.showConnector ? CONNECTOR_WIDTH * 3 / 4 : 0;
switch (this.#bestHorizontalAlignment) {
case DialogHorizontalAlignment.LEFT: {
// Position the dialog such that its left border is in line with that of its anchor.
// If this means the dialog's left border is out of DevTools bounds, move it to the right.
// Cap its width as needed so that the right border doesn't overflow.
const dialogLeft = Math.max(anchorLeft - offsetToCoverConnector, devToolsLeft);
const devtoolsRightBorderToDialogLeft = devToolsRight - dialogLeft;
const dialogMaxWidth = devtoolsRightBorderToDialogLeft - DIALOG_PADDING_FROM_WINDOW;
connectorRelativeXValue = connectorFixedXValue - dialogLeft - DIALOG_SIDE_PADDING;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-left', `${dialogLeft}px`);
this.#hitArea.x = anchorLeft;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-max-width', `${dialogMaxWidth}px`);
break;
}
case DialogHorizontalAlignment.RIGHT: {
// Position the dialog such that its right border is in line with that of its anchor.
// If this means the dialog's right border is out of DevTools bounds, move it to the left.
// Cap its width as needed so that the left border doesn't overflow.
const windowRightBorderToAnchorRight = windowWidth - anchorRight;
const windowRightBorderToDevToolsRight = windowWidth - devToolsRight;
const windowRightBorderToDialogRight =
Math.max(windowRightBorderToAnchorRight - offsetToCoverConnector, windowRightBorderToDevToolsRight);
const dialogRight = windowWidth - windowRightBorderToDialogRight;
const devtoolsLeftBorderToDialogRight = dialogRight - devToolsLeft;
const dialogMaxWidth = devtoolsLeftBorderToDialogRight - DIALOG_PADDING_FROM_WINDOW;
const dialogCappedWidth = Math.min(dialogMaxWidth, dialogWidth);
const dialogLeft = dialogRight - dialogCappedWidth;
connectorRelativeXValue = connectorFixedXValue - dialogLeft;
this.#hitArea.x = windowWidth - windowRightBorderToDialogRight - hitAreaWidth;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-right', `${windowRightBorderToDialogRight}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-max-width', `${dialogMaxWidth}px`);
break;
}
case DialogHorizontalAlignment.CENTER: {
// Position the dialog aligned with its anchor's center as long as its borders don't overlap
// with those of DevTools. In case one border overlaps, move the dialog to the opposite side.
// In case both borders overlap, reduce its width to that of DevTools.
const dialogCappedWidth = Math.min(devToolsWidth - DIALOG_PADDING_FROM_WINDOW, dialogWidth);
let dialogLeft = Math.max(originCenterX - dialogCappedWidth * 0.5, devToolsLeft);
dialogLeft = Math.min(dialogLeft, devToolsRight - dialogCappedWidth);
connectorRelativeXValue = connectorFixedXValue - dialogLeft - DIALOG_SIDE_PADDING;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-left', `${dialogLeft}px`);
this.#hitArea.x = originCenterX - hitAreaWidth * 0.5;
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-max-width', `${devToolsWidth - DIALOG_PADDING_FROM_WINDOW}px`);
break;
}
default:
Platform.assertNever(
this.#bestHorizontalAlignment, `Unknown alignment type: ${this.#bestHorizontalAlignment}`);
}
const visibleConnectorHeight = this.showConnector ? CONNECTOR_HEIGHT : 0;
const clipPathConnectorStartX = connectorRelativeXValue - CONNECTOR_WIDTH / 2;
const clipPathConnectorEndX = connectorRelativeXValue + CONNECTOR_WIDTH / 2;
let [p1, p2, p3, p4, p5, p6, p7, p8, p9] = ['', '', '', '', '', '', '', '', '', ''];
const PSEUDO_BORDER_RADIUS = 2;
switch (this.#bestVerticalPositionInternal) {
case DialogVerticalPosition.TOP: {
// p1 p2
// *-----------------------------*
// | |
// | |
// p9| |
// \__________________p7 p5____/ p3 <-- A pseudo curve is added to the clip path to
// p8 \/ p4 imitate a curved boder.
// p6
// |-connectorRelativeX--|
const clipPathBottom = `calc(100% - ${CONNECTOR_HEIGHT}px)`;
if (this.#props.showConnector) {
p1 = '0 0';
p2 = '100% 0';
p3 = `100% calc(${clipPathBottom} - ${PSEUDO_BORDER_RADIUS}px)`;
p4 = `calc(100% - ${PSEUDO_BORDER_RADIUS}px) ${clipPathBottom}`;
p5 = `${clipPathConnectorStartX}px ${clipPathBottom}`;
p6 = `${connectorRelativeXValue}px 100%`;
p7 = `${clipPathConnectorEndX}px ${clipPathBottom}`;
p8 = `${PSEUDO_BORDER_RADIUS}px ${clipPathBottom}`;
p9 = `0 calc(${clipPathBottom} - ${PSEUDO_BORDER_RADIUS}px)`;
}
ComponentHelpers.SetCSSProperty.set(
this, '--content-padding-bottom',
`${CONNECTOR_HEIGHT + (this.#props.showConnector ? CONNECTOR_HEIGHT : 0)}px`);
ComponentHelpers.SetCSSProperty.set(this, '--content-padding-top', `${CONNECTOR_HEIGHT}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-top', '0');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin', 'auto');
ComponentHelpers.SetCSSProperty.set(this, '--dialog-margin-bottom', `${innerHeight - anchorTop}px`);
this.#hitArea.y = anchorTop - 2 * CONNECTOR_HEIGHT;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-offset-y', `${DIALOG_ANIMATION_OFFSET}px`);
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-max-height',
`${
devToolsHeight - (innerHeight - anchorTop) - DIALOG_PADDING_FROM_WINDOW -
visibleConnectorHeight}px`);
break;
}
case DialogVerticalPosition.BOTTOM: {
// p4
// p2_________/\_________p6
// / p3 p5 \
// p1 | | p7
// | |
// p9 *________________________* p8
if (this.#props.showConnector) {
p1 = `0 ${CONNECTOR_HEIGHT + PSEUDO_BORDER_RADIUS}px`;
p2 = `${PSEUDO_BORDER_RADIUS}px ${CONNECTOR_HEIGHT}px`;
p3 = `${clipPathConnectorStartX}px ${CONNECTOR_HEIGHT}px`;
p4 = `${connectorRelativeXValue}px 0`;
p5 = `${clipPathConnectorEndX}px ${CONNECTOR_HEIGHT}px`;
p6 = `calc(100% - ${PSEUDO_BORDER_RADIUS}px) ${CONNECTOR_HEIGHT}px`;
p7 = `100% ${CONNECTOR_HEIGHT + PSEUDO_BORDER_RADIUS}px`;
p8 = '100% 100%';
p9 = '0 100%';
}
ComponentHelpers.SetCSSProperty.set(
this, '--content-padding-top',
`${CONNECTOR_HEIGHT + (this.#props.showConnector ? CONNECTOR_HEIGHT : 0)}px`);
ComponentHelpers.SetCSSProperty.set(this, '--content-padding-bottom', `${CONNECTOR_HEIGHT}px`);
ComponentHelpers.SetCSSProperty.set(this, '--dialog-top', `${anchorBottom}px`);
this.#hitArea.y = anchorTop;
ComponentHelpers.SetCSSProperty.set(this, '--dialog-offset-y', `-${DIALOG_ANIMATION_OFFSET}px`);
ComponentHelpers.SetCSSProperty.set(
this, '--dialog-max-height',
`${
devToolsHeight - (anchorBottom - devToolsTop) - DIALOG_PADDING_FROM_WINDOW -
visibleConnectorHeight}px`);
break;
}
default:
Platform.assertNever(
this.#bestVerticalPositionInternal, `Unknown position type: ${this.#bestVerticalPositionInternal}`);
}
const clipPath = [p1, p2, p3, p4, p5, p6, p7, p8, p9].join();
ComponentHelpers.SetCSSProperty.set(this, '--content-clip-path', clipPath);
dialog.close();
dialog.style.visibility = '';
});
});
}
async #showDialog(): Promise<void> {
if (!IS_DIALOG_SUPPORTED) {
return;
}
if (this.#isPendingShowDialog || this.hasAttribute('open')) {
return;
}
this.#isPendingShowDialog = true;
this.#positionDialog();
// Allow the CSS variables to be set before showing.
await coordinator.done();
this.#isPendingShowDialog = false;
const dialog = this.#getDialog();
// Make the dialog visible now.
if (!dialog.hasAttribute('open')) {
dialog.showModal();
}
if (this.#props.dialogShownCallback) {
await this.#props.dialogShownCallback();
}
this.#updateDialogBounds();
}
#handleScrollAttempt(event: WheelEvent): void {
if (this.#mouseEventWasInDialogContent(event) || !this.#props.closeOnScroll ||
!this.#getDialog().hasAttribute('open')) {
return;
}
this.#closeDialog();
this.dispatchEvent(new ForcedDialogClose());
}
#onKeyDown(event: KeyboardEvent): void {
if (!this.#getDialog().hasAttribute('open') || !this.#props.closeOnESC) {
return;
}
if (event.key !== Platform.KeyboardUtilities.ESCAPE_KEY) {
return;
}
event.stopPropagation();
event.preventDefault();
this.#closeDialog();
this.dispatchEvent(new ForcedDialogClose());
}
#onCancel(event: Event): void {
event.stopPropagation();
event.preventDefault();
if (!this.#getDialog().hasAttribute('open') || !this.#props.closeOnESC) {
return;
}
this.dispatchEvent(new ForcedDialogClose());
}
#forceDialogCloseInDevToolsMutation(): void {
if (!this.#dialog?.hasAttribute('open')) {
return;
}
if (this.#devToolsBoundingElement === document.body) {
// Do not close if running in test environment.
return;
}
this.#closeDialog();
this.dispatchEvent(new ForcedDialogClose());
}
#closeDialog(): void {
if (this.#isPendingCloseDialog || !this.#getDialog().hasAttribute('open')) {
return;
}
this.#isPendingCloseDialog = true;
void coordinator.write(() => {
this.#hitArea.width = 0;
this.removeAttribute('open');
this.#getDialog().close();
this.#isPendingCloseDialog = false;
});
}
getDialogBounds(): DOMRect {
return this.#dialogClientRect;
}
#render(): void {
if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) {
throw new Error('Dialog render was not scheduled');
}
if (!IS_DIALOG_SUPPORTED) {
// To make sure that light dom content passed into this component doesn't show up,
// we have to explicitly render a slot and hide it with CSS.
LitHtml.render(
// clang-format off
LitHtml.html`
<slot></slot>
`, this.#shadow, {host: this});
// clang-format on
return;
}
// clang-format off
LitHtml.render(LitHtml.html`
<dialog =${this.#handlePointerEvent} =${this.#handlePointerEvent} =${this.#onCancel}>
<div id="content-wrap">
<div id="content">
<slot></slot>
</div>
</div>
</dialog>
`, this.#shadow, { host: this });
// clang-format on
}
}
ComponentHelpers.CustomElements.defineComponent('devtools-dialog', Dialog);
declare global {
interface HTMLElementTagNameMap {
'devtools-dialog': Dialog;
}
}
export class PointerLeftDialogEvent extends Event {
static readonly eventName = 'pointerleftdialog';
constructor() {
super(PointerLeftDialogEvent.eventName, {bubbles: true, composed: true});
}
}
export class ClickOutsideDialogEvent extends Event {
static readonly eventName = 'clickoutsidedialog';
constructor() {
super(ClickOutsideDialogEvent.eventName, {bubbles: true, composed: true});
}
}
export class ForcedDialogClose extends Event {
static readonly eventName = 'forceddialogclose';
constructor() {
super(ForcedDialogClose.eventName, {bubbles: true, composed: true});
}
}
export const enum DialogVerticalPosition {
TOP = 'top',
BOTTOM = 'bottom',
AUTO = 'auto',
}
export const enum DialogHorizontalAlignment {
// Dialog and anchor are aligned on their left borders.
LEFT = 'left',
// Dialog and anchor are aligned on their right borders.
RIGHT = 'right',
CENTER = 'center',
// This option allows to set the alignment
// automatically to LEFT or RIGHT depending
// on whether the dialog overflows the
// viewport if it's aligned to the left.
AUTO = 'auto',
}
type AnchorBounds = {
top: number,
bottom: number,
left: number,
right: number,
};