UNPKG

chrome-devtools-frontend

Version:
439 lines (386 loc) • 13.3 kB
// 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, ''); }