UNPKG

chrome-devtools-frontend

Version:
427 lines (388 loc) • 14.1 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../core/common/common.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import * as CommentManager from '../../models/comment_manager/comment_manager.js'; import * as Comments from '../../ui/comments/comments.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as Lit from '../../ui/lit/lit.js'; import commentsOverlayStyles from './commentsOverlay.css.js'; import {CommentThreadWidget, type Title} from './CommentThreadWidget.js'; const { html, render, nothing, Directives: {repeat, styleMap}, } = Lit; export interface ViewInput { pins: Comments.CommentOverlayManager.PinPositionData[]; highlights: Comments.CommentOverlayManager.HighlightRectData[]; hoverHighlight: Comments.CommentOverlayManager.HoverHighlightData|null; commentMode: boolean; onPinClick: (threadId: string) => void; activeThread: CommentManager.CommentManager.CommentThread|null; activePin: Comments.CommentOverlayManager.PinPositionData|null; title: Title; onAddComment: (text: string) => void; } export type View = ( input: ViewInput, output: undefined, target: HTMLElement, ) => void; const POPUP_MARGIN = 8; const PIN_HEIGHT = 30; const POPUP_WIDTH = 288; const POPUP_HEIGHT = 220; const AUTO_CLOSE_DELAY_MS = 2000; const DEFAULT_VIEW: View = (input: ViewInput, _output: undefined, target: HTMLElement): void => { // clang-format off render(html` <style>${commentsOverlayStyles}</style> <div class="comments-overlay-container"> ${input.hoverHighlight && input.hoverHighlight.visible ? html` <div class="comment-hover-highlight" style=${styleMap({ top: `${input.hoverHighlight.top}px`, left: `${input.hoverHighlight.left}px`, width: `${input.hoverHighlight.width}px`, height: `${input.hoverHighlight.height}px`, })}> </div> ` : nothing} ${input.highlights.map(h => h.visible ? html` <div class="comment-anchor-highlight" style=${styleMap({ top: `${h.top}px`, left: `${h.left}px`, width: `${h.width}px`, height: `${h.height}px`, })}> </div> ` : nothing)} ${input.pins.map(p => p.visible ? html` <div class="comment-pin" style=${styleMap({ top: `${p.top}px`, left: `${p.left}px`, })} @click=${() => input.onPinClick(p.id)}> <div class="comment-cursor">${p.index}</div> </div> ` : nothing)} ${input.activePin && input.activeThread ? repeat( [{pin: input.activePin, thread: input.activeThread}], item => item.thread.id, item => html` <div class="comment-popup-widget" style=${styleMap({ top: `${Math.min( Math.max(POPUP_MARGIN, item.pin.top + PIN_HEIGHT), Math.max(POPUP_MARGIN, target.clientHeight - POPUP_HEIGHT), )}px`, left: `${Math.min( Math.max(POPUP_MARGIN, item.pin.left), Math.max(POPUP_MARGIN, target.clientWidth - POPUP_WIDTH - POPUP_MARGIN), )}px`, })}> ${UI.Widget.widget(CommentThreadWidget, { title: input.title, comments: [...item.thread.comments], onAddComment: input.onAddComment, })} </div> `, ) : nothing} </div> `, target); // clang-format on }; export class CommentsOverlayWidget extends UI.Widget.Widget { static override readonly INJECT: readonly[typeof CommentManager.CommentManager.CommentManager] = [CommentManager.CommentManager.CommentManager] as const; readonly #view: View; readonly #commentManager: CommentManager.CommentManager.CommentManager; #commentOverlayManager: Comments.CommentOverlayManager.CommentOverlayManager; #activeThreadId: string|null = null; #closeTimeoutId: number|null = null; #cachedTitle: Title = {text: ''}; #cachedTitleAnchor: CommentManager.CommentManager.CommentAnchorSignature|null = null; constructor( element: HTMLElement|undefined, [commentManager]: UI.Widget.WidgetDependencies<typeof CommentsOverlayWidget>, view: View = DEFAULT_VIEW, ) { super(element, {useShadowDom: false}); this.#view = view; this.#commentManager = commentManager; this.#commentOverlayManager = new Comments.CommentOverlayManager.CommentOverlayManager( this.#commentManager, ); } setOverlayManagerForTest(overlayManager: Comments.CommentOverlayManager.CommentOverlayManager): void { this.#commentOverlayManager = overlayManager; } override wasShown(): void { super.wasShown(); this.#commentOverlayManager.start(); this.#commentOverlayManager.addEventListener( Comments.CommentOverlayManager.Events.POSITIONS_UPDATED, this.#onStateChanged, this, ); this.#commentOverlayManager.addEventListener( Comments.CommentOverlayManager.Events.HOVER_HIGHLIGHT_CHANGED, this.#onStateChanged, this, ); this.#commentManager.addEventListener( CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, this.#onStateChanged, this, ); this.#commentManager.addEventListener( CommentManager.CommentManager.Events.COMMENT_MODE_CHANGED, this.#onCommentModeChanged, this, ); this.#commentManager.addEventListener( CommentManager.CommentManager.Events.AGENT_ATTACHED_CHANGED, this.#onAgentAttachedChanged, this, ); this.requestUpdate(); } override willHide(): void { this.#clearCloseTimeout(); this.#commentOverlayManager.stop(); this.#commentOverlayManager.removeEventListener( Comments.CommentOverlayManager.Events.POSITIONS_UPDATED, this.#onStateChanged, this, ); this.#commentOverlayManager.removeEventListener( Comments.CommentOverlayManager.Events.HOVER_HIGHLIGHT_CHANGED, this.#onStateChanged, this, ); this.#commentManager.removeEventListener( CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, this.#onStateChanged, this, ); this.#commentManager.removeEventListener( CommentManager.CommentManager.Events.COMMENT_MODE_CHANGED, this.#onCommentModeChanged, this, ); this.#commentManager.removeEventListener( CommentManager.CommentManager.Events.AGENT_ATTACHED_CHANGED, this.#onAgentAttachedChanged, this, ); super.willHide(); } #clearCloseTimeout(): void { if (this.#closeTimeoutId !== null) { window.clearTimeout(this.#closeTimeoutId); this.#closeTimeoutId = null; } } #onAgentAttachedChanged( event: Common.EventTarget.EventTargetEvent< CommentManager.CommentManager.EventTypes[CommentManager.CommentManager.Events.AGENT_ATTACHED_CHANGED]>, ): void { if (!event.data) { this.#activeThreadId = null; } this.requestUpdate(); } #onCommentModeChanged( event: Common.EventTarget.EventTargetEvent< CommentManager.CommentManager.EventTypes[CommentManager.CommentManager.Events.COMMENT_MODE_CHANGED]>, ): void { const isModeActive = event.data; if (!isModeActive) { this.#activeThreadId = null; } const action = UI.ActionRegistry.ActionRegistry.instance().getAction( 'comments.toggle-comment-mode', ); action?.setToggled(isModeActive); this.requestUpdate(); } #onStateChanged(): void { const draftThread = this.#commentManager.getCommentThreads().find(t => t.status === 'DRAFT'); if (draftThread) { this.#activeThreadId = draftThread.id; } else if (this.#activeThreadId && !this.#commentManager.getCommentThread(this.#activeThreadId)) { this.#activeThreadId = null; } this.requestUpdate(); } async #getOrComputeTitle(anchor: CommentManager.CommentManager.CommentAnchorSignature|null): Promise<Title> { if (anchor === this.#cachedTitleAnchor) { return this.#cachedTitle; } const title = anchor ? await this.#computeTitle(anchor) : {text: ''}; this.#cachedTitleAnchor = anchor; this.#cachedTitle = title; return title; } async #computeTitle(anchor: CommentManager.CommentManager.CommentAnchorSignature): Promise<Title> { if (anchor.node) { const target = SDK.TargetManager.TargetManager.instance().targetById(anchor.node.targetId); if (target) { const deferredNode = new SDK.DOMModel.DeferredDOMNode( target, anchor.node.backendNodeId as Protocol.DOM.BackendNodeId, ); const node = await deferredNode.resolvePromise(); if (node) { return {node}; } } return {text: ''}; } if (anchor.networkRequestId) { const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const request = target?.model(SDK.NetworkManager.NetworkManager)?.requestForId(anchor.networkRequestId); if (request) { return {text: request.name()}; } } return {text: anchor.textSignature || ''}; } #handlePinClick = (threadId: string): void => { const thread = this.#commentManager.getCommentThread(threadId); if (this.#activeThreadId === threadId) { if (thread?.status === 'DRAFT') { this.#commentOverlayManager.clearDraftThreads(); } this.#activeThreadId = null; } else { this.#commentOverlayManager.clearDraftThreads(); this.#activeThreadId = threadId; } this.requestUpdate(); }; override async performUpdate(signal?: AbortSignal): Promise<void> { if (!this.#commentManager.isAgentAttached()) { this.#view( { pins: [], highlights: [], hoverHighlight: null, commentMode: false, onPinClick: this.#handlePinClick, activeThread: null, activePin: null, title: {text: ''}, onAddComment: () => {}, }, undefined, this.contentElement, ); return; } const activeThread = this.#activeThreadId ? this.#commentManager.getCommentThread(this.#activeThreadId) ?? null : null; const title = await this.#getOrComputeTitle(activeThread?.anchor ?? null); signal?.throwIfAborted(); const pins = this.#commentOverlayManager.getPinPositions(); const highlights = this.#commentOverlayManager.getHighlightRects(); const activePin = this.#activeThreadId ? pins.find(p => p.id === this.#activeThreadId) ?? null : null; const viewInput: ViewInput = { pins, highlights, hoverHighlight: this.#commentOverlayManager.getHoverHighlight(), commentMode: this.#commentManager.isCommentMode(), onPinClick: this.#handlePinClick, activeThread, activePin, title, onAddComment: (text: string) => { if (!activeThread) { return; } activeThread.save(text); const threadId = activeThread.id; this.#closeTimeoutId = window.setTimeout(() => { this.#closeTimeoutId = null; if (this.#activeThreadId === threadId) { this.#activeThreadId = null; this.requestUpdate(); } }, AUTO_CLOSE_DELAY_MS); }, }; this.#view(viewInput, undefined, this.contentElement); } } let widgetInstance: CommentsOverlayWidget|null = null; export class ActionDelegate implements UI.ActionRegistration.ActionDelegate { readonly #commentManager: CommentManager.CommentManager.CommentManager; constructor(commentManager?: CommentManager.CommentManager.CommentManager) { this.#commentManager = commentManager ?? Root.DevToolsContext.globalInstance().get( CommentManager.CommentManager.CommentManager, ); } handleAction(_context: UI.Context.Context, actionId: string): boolean { if (actionId === 'comments.toggle-comment-mode') { if (!this.#commentManager.isAgentAttached()) { return false; } if (!widgetInstance) { widgetInstance = new CommentsOverlayWidget( undefined, [this.#commentManager], ); widgetInstance.markAsRoot(); widgetInstance.show(document.body); } this.#commentManager.setCommentMode( !this.#commentManager.isCommentMode(), ); return true; } return false; } static resetForTest(): void { if (widgetInstance) { widgetInstance.detach(); widgetInstance = null; } } } export class ButtonProvider implements UI.Toolbar.Provider { readonly #button: UI.Toolbar.ToolbarButton; readonly #commentManager: CommentManager.CommentManager.CommentManager; constructor(commentManager?: CommentManager.CommentManager.CommentManager) { this.#commentManager = commentManager ?? Root.DevToolsContext.globalInstance().get( CommentManager.CommentManager.CommentManager, ); const action = UI.ActionRegistry.ActionRegistry.instance().getAction('comments.toggle-comment-mode'); action.setEnabled(this.#commentManager.isAgentAttached()); this.#button = UI.Toolbar.Toolbar.createActionButton(action); this.#button.setVisible(this.#commentManager.isAgentAttached()); this.#commentManager.addEventListener( CommentManager.CommentManager.Events.AGENT_ATTACHED_CHANGED, event => { action.setEnabled(event.data); this.#button.setVisible(event.data); }, ); } item(): UI.Toolbar.ToolbarItem|null { return this.#button; } }