chrome-devtools-frontend
Version:
Chrome DevTools UI
641 lines (572 loc) • 20.8 kB
text/typescript
// 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 * as Common from '../../core/common/common.js';
import {
type CommentThread,
deepQuerySelectorAll,
isElementVisible,
rematchCommentAnchor,
resolveCommentAnchor,
resolveCommentAnchorElement,
} from './CommentAnchorResolver.js';
export interface StartOptions {
root?: Document|Element;
scrollTarget?: EventTarget;
resizeTarget?: Element;
defaultText?: string;
}
export interface PinPositionData {
id: string;
top: number;
left: number;
visible: boolean;
}
export interface HighlightRectData {
id: string;
top: number;
left: number;
width: number;
height: number;
visible: boolean;
}
export interface HoverHighlightData {
top: number;
left: number;
width: number;
height: number;
visible: boolean;
}
export interface EventTypes {
[Events.COMMENT_THREADS_CHANGED]: CommentThread[];
[Events.POSITIONS_UPDATED]: {
pins: PinPositionData[],
highlights: HighlightRectData[],
};
[Events.COMMENT_MODE_CHANGED]: boolean;
[Events.HOVER_HIGHLIGHT_CHANGED]: HoverHighlightData|null;
}
export const enum Events {
COMMENT_THREADS_CHANGED = 'CommentThreadsChanged',
POSITIONS_UPDATED = 'PositionsUpdated',
COMMENT_MODE_CHANGED = 'CommentModeChanged',
HOVER_HIGHLIGHT_CHANGED = 'HoverHighlightChanged',
}
/**
* Orchestrates comment threads, coordinates interactive commenting UI mode,
* and tracks live DOM element positions via observers and event listeners.
*/
export class CommentManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
readonly #commentThreads = new Map<string, CommentThread>();
readonly #liveNodeCache = new WeakMap<CommentThread, Element>();
#observedThreads = new WeakSet<Element>();
#intersectionObserver?: IntersectionObserver;
#commentMode = false;
#nextId = 1;
#hoverData: HoverHighlightData|null = null;
#pinPositions: PinPositionData[] = [];
#highlightRects: HighlightRectData[] = [];
#clickListener?: (event: Event) => void;
#hoverListener?: (event: Event) => void;
#suppressListener?: (event: Event) => void;
#clickContainer?: Element|Document;
readonly #hoverEventTypes = [
'mouseover',
'mouseout',
'mouseenter',
'mouseleave',
'pointerover',
'pointerout',
'mousemove',
];
readonly #suppressEventTypes = [
'mousedown',
'pointerdown',
'mouseup',
'pointerup',
'dblclick',
];
#scrollListener?: (event: Event) => void;
#scrollTarget?: EventTarget;
#scrollRafId?: number;
#devToolsResizeObserver?: ResizeObserver;
#resizeRafId?: number;
#mutationObserver?: MutationObserver;
#rematchTimeoutId?: ReturnType<typeof setTimeout>;
/**
* Lazily creates an IntersectionObserver that monitors the visibility and viewport intersection
* of elements with active comment anchors.
*
* As anchored DOM nodes scroll in or out of visible viewport regions or virtualized lists,
* the observer triggers position recalculations to ensure comment pins and highlights are
* positioned accurately or hidden when out of view.
*/
#getIntersectionObserver(): IntersectionObserver {
if (!this.#intersectionObserver) {
this.#intersectionObserver = new IntersectionObserver(() => {
this.#updatePositions();
});
}
return this.#intersectionObserver;
}
setCommentMode(active: boolean): void {
if (this.#commentMode === active) {
return;
}
this.#commentMode = active;
if (!active) {
this.#setHoverHighlight(null);
}
document.body.style.cursor = active ? 'crosshair' : '';
this.dispatchEventToListeners(Events.COMMENT_MODE_CHANGED, active);
}
isCommentMode(): boolean {
return this.#commentMode;
}
#setHoverHighlight(data: HoverHighlightData|null): void {
if (data === null && this.#hoverData === null) {
return;
}
if (data && this.#hoverData && data.top === this.#hoverData.top && data.left === this.#hoverData.left &&
data.width === this.#hoverData.width && data.height === this.#hoverData.height &&
data.visible === this.#hoverData.visible) {
return;
}
this.#hoverData = data;
this.dispatchEventToListeners(Events.HOVER_HIGHLIGHT_CHANGED, data);
}
getHoverHighlight(): HoverHighlightData|null {
return this.#hoverData;
}
getPinPositions(): PinPositionData[] {
return this.#pinPositions;
}
getHighlightRects(): HighlightRectData[] {
return this.#highlightRects;
}
handleElementClick(element: Element, commentText = 'New comment'): CommentThread|null {
if (!this.#commentMode) {
return null;
}
return this.createComment(element, commentText, 'DEVELOPER');
}
createComment(
element: Element,
text: string,
author: 'DEVELOPER'|'AGENT' = 'DEVELOPER',
changes?: Array<Record<string, unknown>>,
): CommentThread|null {
const anchorEl = resolveCommentAnchorElement(element);
const anchor = resolveCommentAnchor(element);
if (!anchor || !anchorEl) {
return null;
}
const id = `comment-${this.#nextId++}`;
const thread: CommentThread = {
id,
anchor,
comments: [{
author,
text,
timestamp: Date.now(),
}],
status: 'ACTIVE',
changes,
};
this.#commentThreads.set(id, thread);
this.#liveNodeCache.set(thread, anchorEl);
const observer = this.#getIntersectionObserver();
observer.observe(anchorEl);
this.#observedThreads.add(anchorEl);
this.#updatePositions();
this.dispatchEventToListeners(Events.COMMENT_THREADS_CHANGED, this.getCommentThreads());
return thread;
}
getCommentThread(id: string): CommentThread|undefined {
return this.#commentThreads.get(id);
}
getCommentThreads(): CommentThread[] {
return Array.from(this.#commentThreads.values());
}
removeCommentThread(id: string): void {
const thread = this.#commentThreads.get(id);
if (!thread) {
return;
}
const el = this.#liveNodeCache.get(thread);
this.#commentThreads.delete(id);
this.#liveNodeCache.delete(thread);
if (el && this.#intersectionObserver) {
let isElementStillObserved = false;
for (const remainingThread of this.#commentThreads.values()) {
if (this.#liveNodeCache.get(remainingThread) === el) {
isElementStillObserved = true;
break;
}
}
if (!isElementStillObserved) {
this.#intersectionObserver.unobserve(el);
this.#observedThreads.delete(el);
}
}
this.#updatePositions();
this.dispatchEventToListeners(Events.COMMENT_THREADS_CHANGED, this.getCommentThreads());
}
/**
* Rematches stored comments to live DOM nodes across dynamic container updates.
*
* Pre-queries [jslog] elements once across the container to avoid redundant deep DOM traversals
* during batch rematching, and cleans up unobserved IntersectionObserver nodes in O(N) time.
*/
#rematchAllComments(root: Document|Element = document): void {
const jslogElements = deepQuerySelectorAll(root, '[jslog]');
const oldElements = new Set<Element>();
const newElements = new Set<Element>();
for (const thread of this.#commentThreads.values()) {
const oldEl = this.#liveNodeCache.get(thread);
if (oldEl) {
oldElements.add(oldEl);
}
// Always try to rematch to support virtualized lists where DOM nodes are recycled.
const el = rematchCommentAnchor(thread, root, jslogElements);
if (el) {
this.#liveNodeCache.set(thread, el);
newElements.add(el);
} else {
this.#liveNodeCache.delete(thread);
}
}
if (this.#intersectionObserver) {
// Unobserve old elements no longer referenced by any comment thread in O(N) time.
for (const oldEl of oldElements) {
if (!newElements.has(oldEl)) {
this.#intersectionObserver.unobserve(oldEl);
this.#observedThreads.delete(oldEl);
}
}
}
this.#updatePositions();
}
#updatePositions(): void {
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const newPins: PinPositionData[] = [];
const newHighlights: HighlightRectData[] = [];
const elementPinCounts = new Map<Element, number>();
for (const thread of this.#commentThreads.values()) {
const el = this.#liveNodeCache.get(thread) || null;
if (!el || !el.isConnected) {
continue;
}
const observer = this.#getIntersectionObserver();
if (!this.#observedThreads.has(el)) {
observer.observe(el);
this.#observedThreads.add(el);
}
if (!isElementVisible(el)) {
continue;
}
const rect = el.getBoundingClientRect();
const offsetIndex = elementPinCounts.get(el) || 0;
elementPinCounts.set(el, offsetIndex + 1);
// Offset by 26px vertically (24px pin icon height + 2px spacing) so multiple comment pins on the same element stack vertically without overlapping.
const offsetY = offsetIndex * 26;
// Offset by -12px (half of the 24px pin diameter) so the pin icon is centered on the top-right corner of the target element.
newPins.push({
id: thread.id,
top: scrollY + rect.top - 12 + offsetY,
left: scrollX + rect.right - 12,
visible: true,
});
newHighlights.push({
id: thread.id,
top: scrollY + rect.top,
left: scrollX + rect.left,
width: rect.width,
height: rect.height,
visible: true,
});
}
this.#pinPositions = newPins;
this.#highlightRects = newHighlights;
this.dispatchEventToListeners(Events.POSITIONS_UPDATED, {
pins: newPins,
highlights: newHighlights,
});
}
/**
* Initializes event listeners and lifecycle observers across the target DOM container.
*
* Sets up:
* - Capturing click, hover, and interaction suppression handlers to coordinate comment placement.
* - A capturing scroll listener on the window to track scrolling across nested subpanes.
* - A ResizeObserver to recalculate overlay coordinates when DevTools panels or drawers are resized.
* - A MutationObserver to automatically rematch existing comment anchors when the DOM re-renders.
*/
start(rootOrOptions?: Document|Element|StartOptions, defaultText = 'New comment'): void {
let root: Document|Element|undefined;
let scrollTarget: EventTarget|undefined;
let resizeTarget: Element|undefined;
let text = defaultText;
if (rootOrOptions && !(rootOrOptions instanceof Document) && !(rootOrOptions instanceof Element)) {
root = rootOrOptions.root;
scrollTarget = rootOrOptions.scrollTarget;
resizeTarget = rootOrOptions.resizeTarget;
text = rootOrOptions.defaultText ?? defaultText;
} else if (rootOrOptions) {
root = rootOrOptions;
}
root = root || document;
scrollTarget = scrollTarget || (root instanceof Document ? (root.defaultView || window) : window);
resizeTarget = resizeTarget || (root instanceof Document ? (root.body || root.documentElement) : root);
this.stop();
this.#installClickListener(root, text);
this.#installScrollListener(scrollTarget);
this.#installResizeObserver(resizeTarget);
this.#installMutationObserver(root);
}
/**
* Stops and detaches all active listeners and observers without clearing comment threads.
*/
stop(): void {
this.#removeClickListener();
this.#removeScrollListener();
this.#removeResizeObserver();
this.#removeMutationObserver();
}
/**
* Sets up capturing click, hover, and pointer interaction listeners on the container.
*
* When Comment Mode is active:
* - Clicks on anchorable elements create new comment threads and consume the click event,
* preventing normal DevTools UI triggers such as node selection or navigation.
* - Pointer and mouse press events are suppressed to prevent accidental text selections or drag interactions.
* - Hover events compute and display a real-time preview highlight over the candidate anchor element.
*/
#installClickListener(container: Element|Document = document, defaultText = 'New comment'): void {
this.#removeClickListener();
this.#clickContainer = container;
this.#clickListener = (event: Event): void => {
if (!this.#commentMode) {
return;
}
const composedTarget = event.composedPath()[0];
const target = (composedTarget instanceof Element) ? composedTarget : event.target;
if (!(target instanceof Element)) {
return;
}
const thread = this.handleElementClick(target, defaultText);
if (thread) {
event.consume(true);
}
};
this.#suppressListener = (event: Event): void => {
if (!this.#commentMode) {
return;
}
const composedTarget = event.composedPath()[0];
const target = (composedTarget instanceof Element) ? composedTarget : event.target;
if (!(target instanceof Element)) {
return;
}
const anchorEl = resolveCommentAnchorElement(target);
if (anchorEl) {
event.consume(true);
}
};
this.#hoverListener = (event: Event): void => {
if (!this.#commentMode) {
this.#setHoverHighlight(null);
return;
}
const composedTarget = event.composedPath()[0];
const target = (composedTarget instanceof Element) ? composedTarget : event.target;
if (!(target instanceof Element)) {
this.#setHoverHighlight(null);
return;
}
const isLeaveEvent = event.type === 'mouseout' || event.type === 'mouseleave' || event.type === 'pointerout';
const anchorEl = resolveCommentAnchorElement(target);
if (isLeaveEvent) {
const relatedTarget = (event as MouseEvent | PointerEvent).relatedTarget;
if (anchorEl && relatedTarget instanceof Node && anchorEl.contains(relatedTarget)) {
event.consume(true);
return;
}
this.#setHoverHighlight(null);
if (anchorEl) {
event.consume(true);
}
return;
}
if (anchorEl) {
const cmLine = target.closest('.cm-line');
const highlightTarget = (cmLine && anchorEl.classList.contains('cm-editor')) ? cmLine : anchorEl;
const rect = highlightTarget.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
this.#setHoverHighlight({
top: scrollY + rect.top,
left: scrollX + rect.left,
width: rect.width,
height: rect.height,
visible: true,
});
event.consume(true);
} else {
this.#setHoverHighlight(null);
}
};
container.addEventListener('click', this.#clickListener, {capture: true});
for (const type of this.#suppressEventTypes) {
container.addEventListener(type, this.#suppressListener, {capture: true});
}
for (const type of this.#hoverEventTypes) {
container.addEventListener(type, this.#hoverListener, {capture: true});
}
}
#removeClickListener(): void {
if (this.#clickContainer) {
if (this.#clickListener) {
this.#clickContainer.removeEventListener('click', this.#clickListener, {capture: true});
}
if (this.#suppressListener) {
for (const type of this.#suppressEventTypes) {
this.#clickContainer.removeEventListener(type, this.#suppressListener, {capture: true});
}
}
if (this.#hoverListener) {
for (const type of this.#hoverEventTypes) {
this.#clickContainer.removeEventListener(type, this.#hoverListener, {capture: true});
}
}
this.#clickListener = undefined;
this.#suppressListener = undefined;
this.#hoverListener = undefined;
this.#clickContainer = undefined;
}
}
/**
* Registers a capturing scroll listener on the window or target container.
*
* Because DevTools contains multiple independently scrolling subpanes (such as the Elements tree,
* Sources editor, and Network list), a capturing listener on the top-level window catches scroll
* events anywhere in the tree and schedules a throttled position update using requestAnimationFrame.
*/
#installScrollListener(target: EventTarget = window): void {
this.#removeScrollListener();
this.#scrollTarget = target;
this.#scrollListener = (): void => {
this.#setHoverHighlight(null);
if (this.#scrollRafId !== undefined) {
cancelAnimationFrame(this.#scrollRafId);
}
this.#scrollRafId = requestAnimationFrame(() => {
this.#scrollRafId = undefined;
this.#updatePositions();
});
};
target.addEventListener('scroll', this.#scrollListener, {capture: true, passive: true});
}
#removeScrollListener(): void {
if (this.#scrollTarget && this.#scrollListener) {
this.#scrollTarget.removeEventListener('scroll', this.#scrollListener, {capture: true});
this.#scrollListener = undefined;
this.#scrollTarget = undefined;
}
if (this.#scrollRafId !== undefined) {
cancelAnimationFrame(this.#scrollRafId);
this.#scrollRafId = undefined;
}
}
/**
* Observes dimensions of the root element to react to layout changes.
*
* Resizing DevTools windows, adjusting drawer splitters, or toggling sidebars alters the bounding
* boxes of anchored elements. The observer ensures overlay pins and highlight boxes are updated
* whenever container dimensions change.
*/
#installResizeObserver(element: Element = document.body): void {
this.#removeResizeObserver();
this.#devToolsResizeObserver = new ResizeObserver(() => {
this.#setHoverHighlight(null);
if (this.#resizeRafId !== undefined) {
cancelAnimationFrame(this.#resizeRafId);
}
this.#resizeRafId = requestAnimationFrame(() => {
this.#resizeRafId = undefined;
this.#updatePositions();
});
});
this.#devToolsResizeObserver.observe(element);
}
#removeResizeObserver(): void {
if (this.#devToolsResizeObserver) {
this.#devToolsResizeObserver.disconnect();
this.#devToolsResizeObserver = undefined;
}
if (this.#resizeRafId !== undefined) {
cancelAnimationFrame(this.#resizeRafId);
this.#resizeRafId = undefined;
}
}
/**
* Debounces rematching of comments across dynamic DOM updates.
*/
#scheduleRematch(root: Document|Element = document): void {
if (this.#rematchTimeoutId) {
clearTimeout(this.#rematchTimeoutId);
}
this.#rematchTimeoutId = setTimeout(() => {
this.#rematchTimeoutId = undefined;
this.#rematchAllComments(root);
}, 250);
}
/**
* Monitors DOM tree additions, removals, and attribute mutations across the container.
*
* DevTools inspector panes dynamically re-render items when expanding trees, filtering results,
* or updating state. By observing child list mutations and key domain attributes (`jslog`,
* `data-network-request-id`, `data-backend-node-id`, `aria-expanded`), the manager schedules a
* debounced rematch so comments remain bound to their corresponding live DOM elements.
*/
#installMutationObserver(root: Document|Element = document): void {
this.#removeMutationObserver();
this.#mutationObserver = new MutationObserver(() => {
this.#scheduleRematch(root);
});
const targetNode = root instanceof Document ? (root.body || root.documentElement) : root;
this.#mutationObserver.observe(targetNode, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['jslog', 'data-network-request-id', 'data-backend-node-id', 'aria-expanded'],
});
}
#removeMutationObserver(): void {
if (this.#mutationObserver) {
this.#mutationObserver.disconnect();
this.#mutationObserver = undefined;
}
if (this.#rematchTimeoutId) {
clearTimeout(this.#rematchTimeoutId);
this.#rematchTimeoutId = undefined;
}
}
/**
* Fully resets the manager by disabling comment mode, disconnecting all observers and listeners,
* and purging all active comment threads and overlay data.
*/
clear(): void {
this.setCommentMode(false);
this.stop();
this.#intersectionObserver?.disconnect();
this.#intersectionObserver = undefined;
this.#observedThreads = new WeakSet();
this.#commentThreads.clear();
this.#pinPositions = [];
this.#highlightRects = [];
this.#updatePositions();
this.dispatchEventToListeners(Events.COMMENT_THREADS_CHANGED, []);
}
}