chrome-devtools-frontend
Version:
Chrome DevTools UI
439 lines (386 loc) • 13.3 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as GreenDev from '../../models/greendev/greendev.js';
import * as Logs from '../../models/logs/logs.js';
import * as Trace from '../../models/trace/trace.js';
import * as Buttons from '../components/buttons/buttons.js';
import {html, render, type TemplateResult} from '../lit/lit.js';
import {Context} from './Context.js';
import floatyStyles from './floaty.css.js';
import {Widget} from './Widget.js';
let instance: Floaty|null = null;
export const enum FloatyContextTypes {
ELEMENT_NODE_ID = 'ELEMENT_NODE_ID',
NETWORK_REQUEST = 'NETWORK_REQUEST',
PERFORMANCE_EVENT = 'PERFORMANCE_EVENT',
PERFORMANCE_INSIGHT = 'PERFORMANCE_INSIGHT'
}
export type FloatyContextSelection = SDK.DOMModel.DOMNode|SDK.NetworkRequest.NetworkRequest|
{event: Trace.Types.Events.Event, traceStartTime: Trace.Types.Timing.Micro}|
{insight: Trace.Insights.Types.InsightModel, trace: Trace.TraceModel.ParsedTrace};
const enum State {
READONLY = 'readonly',
INSPECT_MODE = 'inspect',
}
export class FloatyFlavor {
selectedContexts: FloatyContextSelection[] = [];
constructor(contexts: Set<FloatyContextSelection>) {
this.selectedContexts = Array.from(contexts);
}
}
export class Floaty {
static defaultVisibility = false;
#container: HTMLElement;
#floaty: FloatyUI;
#boundKeyDown = this.#onKeyShortcut.bind(this);
static exists(): boolean {
return instance !== null;
}
static instance(opts: {
forceNew: boolean|null,
document: Document|null,
} = {forceNew: null, document: null}): Floaty {
if (instance) {
return instance;
}
if (!opts.document) {
throw new Error('document required');
}
instance = new Floaty(opts.document);
return instance;
}
private constructor(document: Document) {
// eslint-disable-next-line @devtools/no-imperative-dom-api
this.#container = document.createElement('div');
this.#container.classList.add('floaty-container');
this.#floaty = new FloatyUI();
this.#floaty.markAsRoot();
this.#insertIntoDOM();
}
#onKeyShortcut(e: KeyboardEvent): void {
const origin = e.composedPath().at(0);
// If the user was typing into an input field, don't make it trigger the Floaty.
if (origin && (origin instanceof HTMLTextAreaElement || origin instanceof HTMLInputElement)) {
return;
}
if (e.key === 'f') {
this.open();
}
}
#insertIntoDOM(): void {
if (GreenDev.Prototypes.instance().isEnabled('inDevToolsFloaty')) {
this.#floaty.show(this.#container);
document.body.appendChild(this.#container);
document.body.addEventListener('keydown', this.#boundKeyDown);
}
}
setDevToolsRect(rect: DOMRect): void {
this.#floaty.devtoolsRect = rect;
}
open(): void {
this.#floaty.open = true;
}
registerClick(input: Readonly<FloatyClickInput>): void {
if (this.#floaty.state !== State.INSPECT_MODE) {
return;
}
const type = input.type; // Switching on type, rather than input.type, means TS narrows it properly.
switch (type) {
case FloatyContextTypes.ELEMENT_NODE_ID: {
const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!mainTarget) {
return;
}
const domModel = mainTarget.model(SDK.DOMModel.DOMModel);
const node = domModel?.nodeForId(input.data.nodeId);
if (node) {
this.#floaty.addSelectedContext(node);
}
break;
}
case FloatyContextTypes.NETWORK_REQUEST: {
const networkRequests = Logs.NetworkLog.NetworkLog.instance().requestsForId(input.data.requestId);
for (const req of networkRequests) {
this.#floaty.addSelectedContext(req);
}
break;
}
case FloatyContextTypes.PERFORMANCE_EVENT: {
this.#floaty.addSelectedContext(input.data);
break;
}
case FloatyContextTypes.PERFORMANCE_INSIGHT: {
this.#floaty.addSelectedContext(input.data);
break;
}
default:
Platform.assertNever(type, 'Unsupported Floaty Context type');
}
}
inInspectMode(): boolean {
return this.#floaty.state === State.INSPECT_MODE;
}
deleteContext(context: FloatyContextSelection): void {
this.#floaty.removeSelectedContext(context);
}
}
type FloatyClickInput = {
type: FloatyContextTypes.ELEMENT_NODE_ID,
data: {nodeId: Protocol.DOM.NodeId},
}|{
type: FloatyContextTypes.NETWORK_REQUEST,
data: {requestId: string},
}|{
type: FloatyContextTypes.PERFORMANCE_EVENT,
data: {event: Trace.Types.Events.Event, traceStartTime: Trace.Types.Timing.Micro},
}|{
type: FloatyContextTypes.PERFORMANCE_INSIGHT,
data: {
insight: Trace.Insights.Types.InsightModel,
trace: Trace.TraceModel.ParsedTrace,
},
};
export function onFloatyOpen(): void {
if (!GreenDev.Prototypes.instance().isEnabled('inDevToolsFloaty')) {
return;
}
Floaty.instance().open();
}
export function onFloatyContextDelete(context: FloatyContextSelection): void {
if (!GreenDev.Prototypes.instance().isEnabled('inDevToolsFloaty')) {
return;
}
Floaty.instance().deleteContext(context);
}
/**
* Registers a click to the Floaty.
* @returns true if the element was added to the floaty context, and false
* otherwise. This lets callers determine if this should override the default
* click behaviour.
*/
export function onFloatyClick(input: FloatyClickInput): boolean {
if (!GreenDev.Prototypes.instance().isEnabled('inDevToolsFloaty')) {
return false;
}
const floaty = Floaty.instance();
if (floaty.inInspectMode()) {
floaty.registerClick(input);
return true;
}
return false;
}
export class FloatyUI extends Widget {
#view: (input: ViewInput, output: null, target: HTMLElement) => void;
#dialog: HTMLDialogElement|null = null;
#initialMouseX = 0;
#initialMouseY = 0;
#initialDialogLeft = 0;
#initialDialogTop = 0;
#devtoolsRect: DOMRect|null = null;
#selectedContexts = new Set<FloatyContextSelection>();
#open = Floaty.defaultVisibility;
#state = State.READONLY;
constructor(element?: HTMLElement, view = VIEW) {
super(element);
this.#view = view;
}
get devtoolsRect(): DOMRect|null {
return this.#devtoolsRect;
}
get state(): State {
return this.#state;
}
set state(x: State) {
this.#state = x;
this.requestUpdate();
}
set devtoolsRect(rect: DOMRect) {
this.#devtoolsRect = rect;
this.#repositionWithNewRect(rect);
this.requestUpdate();
}
addSelectedContext(context: FloatyContextSelection): void {
if (this.#selectedContexts.has(context)) {
return;
}
this.#selectedContexts.add(context);
Context.instance().setFlavor(FloatyFlavor, new FloatyFlavor(this.#selectedContexts));
this.requestUpdate();
}
removeSelectedContext(context: FloatyContextSelection): void {
this.#selectedContexts.delete(context);
this.#state = State.READONLY;
Context.instance().setFlavor(FloatyFlavor, new FloatyFlavor(this.#selectedContexts));
this.requestUpdate();
}
get open(): boolean {
return this.#open;
}
set open(open: boolean) {
this.#open = open;
this.requestUpdate();
}
override wasShown(): void {
super.wasShown();
this.requestUpdate();
}
#repositionWithNewRect(rect: DOMRect): void {
if (!this.#dialog) {
return;
}
const computedStyle = window.getComputedStyle(this.#dialog);
const currentLeft = parseInt(computedStyle.left, 10);
const currentTop = parseInt(computedStyle.top, 10);
this.#dialog.style.left = `${Math.max(currentLeft, rect.left)}px`;
this.#dialog.style.top = `${Math.max(currentTop, rect.top)}px`;
}
#onInspectClick(): void {
if (this.#state === State.INSPECT_MODE) {
this.#state = State.READONLY;
} else {
this.#state = State.INSPECT_MODE;
}
this.requestUpdate();
}
#onDialogClose(e: PointerEvent): void {
e.preventDefault();
this.#open = false;
this.requestUpdate();
}
#onContextDelete(context: FloatyContextSelection): (e: MouseEvent) => void {
return e => {
e.preventDefault();
this.removeSelectedContext(context);
};
}
override performUpdate(): void {
this.#view(
{
open: this.open,
onDragStart: this.#onDragStart,
selectedContexts: this.#selectedContexts,
state: this.#state,
onInspectClick: this.#onInspectClick.bind(this),
onDialogClose: this.#onDialogClose.bind(this),
onContextDelete: this.#onContextDelete.bind(this)
},
null, this.contentElement);
this.#dialog = this.contentElement.querySelector('dialog');
}
#onDragStart = (event: MouseEvent): void => {
if (!this.#dialog) {
return;
}
this.#initialMouseX = event.clientX;
this.#initialMouseY = event.clientY;
const computedStyle = window.getComputedStyle(this.#dialog);
this.#initialDialogLeft = parseInt(computedStyle.left, 10);
this.#initialDialogTop = parseInt(computedStyle.top, 10);
document.addEventListener('mousemove', this.#onDrag);
document.addEventListener('mouseup', this.#onDragEnd);
};
#onDrag = (event: MouseEvent): void => {
if (!this.#dialog) {
return;
}
const deltaX = event.clientX - this.#initialMouseX;
const deltaY = event.clientY - this.#initialMouseY;
const minLeft = this.#devtoolsRect?.left ?? 0;
const minTop = this.#devtoolsRect?.top ?? 0;
const newLeft = Math.max(minLeft, this.#initialDialogLeft + deltaX);
const newTop = Math.max(minTop, this.#initialDialogTop + deltaY);
this.#dialog.style.left = `${newLeft}px`;
this.#dialog.style.top = `${newTop}px`;
};
#onDragEnd = (): void => {
document.removeEventListener('mousemove', this.#onDrag);
document.removeEventListener('mouseup', this.#onDragEnd);
};
}
interface ViewInput {
onDragStart: (event: MouseEvent) => void;
selectedContexts: ReadonlySet<FloatyContextSelection>;
open: boolean;
state: State;
onInspectClick: () => void;
onDialogClose: (e: PointerEvent) => void;
onContextDelete: (item: FloatyContextSelection) => (e: MouseEvent) => void;
}
const VIEW = (input: ViewInput, _output: null, target: HTMLElement): void => {
const contexts = Array.from(input.selectedContexts);
// clang-format off
render(html`
<style>${floatyStyles}</style>
<dialog ?open=${input.open} @mousedown=${input.onDragStart}>
<header>
<span>DevTools context picker</span>
<devtools-button
class="close-button"
@click=${input.onDialogClose}
.data=${{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'cross',
title: 'Close',
size: Buttons.Button.Size.SMALL,
} as Buttons.Button.ButtonData}
></devtools-button>
</header>
<section class="body">
<section class="contexts">
${contexts.length === 0 ? html`
<span class="no-context">Select items to add them to the AI agent's context.</span>
` : html`
<ul class="floaty-contexts">
${contexts.map(context => {
return html`<li>
<span class="context-item">
${floatyContextToUI(context)}
</span>
<devtools-button
class="close-button"
@click=${input.onContextDelete(context)}
.data=${{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'cross',
title: 'Delete',
size: Buttons.Button.Size.SMALL,
} as Buttons.Button.ButtonData}
></devtools-button>
</li>`;
})}
</ul>
`}
</section>
<section class="actions">
<devtools-button title="Select" @click=${input.onInspectClick}
.active=${input.state === State.INSPECT_MODE}
.variant=${Buttons.Button.Variant.TONAL} .iconName=${'select-element'}
>Select</devtools-button>
</devtools-toolbar>
</section>
</section>
</dialog>`,
target);
// clang-format on
};
function floatyContextToUI(context: FloatyContextSelection): TemplateResult {
if (context instanceof SDK.NetworkRequest.NetworkRequest) {
return html`${context.url()}`;
}
if (context instanceof SDK.DOMModel.DOMNode) {
return html`${context.simpleSelector()}`;
}
if ('insight' in context) {
return html`Insight: ${context.insight.title}`;
}
if ('event' in context && 'traceStartTime' in context) {
const time = Trace.Types.Timing.Micro(context.event.ts - context.traceStartTime);
return html`${context.event.name} @ ${i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(time)}`;
}
Platform.assertNever(context, '');
}