| // 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 * as CommentManager from '../../models/comment_manager/comment_manager.js'; | |
| import { | |
| addCustomAnchorsMovedListener, | |
| clearClippingAncestorsCache, | |
| type CommentAnchorSignature, | |
| type CommentThread, | |
| computeVisibleRect, | |
| type CustomAnchorResolver, | |
| deepQuerySelectorAll, | |
| getCustomAnchorElement, | |
| getCustomAnchorResolverForElement, | |
| getEditorFilePath, | |
| hasDisallowedCommentAncestor, | |
| isDomTrackedAnchor, | |
| rematchCommentAnchor, | |
| removeCustomAnchorsMovedListener, | |
| resolveCommentAnchor, | |
| resolveCommentAnchorElement, | |
| setStickyOccluders, | |
| STICKY_OCCLUDER_ATTRIBUTE, | |
| } from './CommentAnchorResolver.js'; | |
| export const COMMENT_MODE_CURSOR = 'var(--comment-cursor)'; | |
| export interface StartOptions { | |
| root?: Document|Element; | |
| scrollTarget?: EventTarget; | |
| resizeTarget?: Element; | |
| } | |
| 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 CreateCommentOptions { | |
| author?: 'DEVELOPER'|'AGENT'; | |
| isGeneratedComment?: boolean; | |
| coordinates?: {clientX: number, clientY: number}; | |
| } | |
| export interface HoverHighlightData { | |
| top: number; | |
| left: number; | |
| width: number; | |
| height: number; | |
| visible: boolean; | |
| } | |
| export const enum Events { | |
| POSITIONS_UPDATED = 'PositionsUpdated', | |
| HOVER_HIGHLIGHT_CHANGED = 'HoverHighlightChanged', | |
| } | |
| export interface EventTypes { | |
| [Events.POSITIONS_UPDATED]: { | |
| pins: PinPositionData[], | |
| highlights: HighlightRectData[], | |
| isRealtimeSync?: boolean, | |
| }; | |
| [Events.HOVER_HIGHLIGHT_CHANGED]: HoverHighlightData|null; | |
| } | |
| /** | |
| * Orchestrates live DOM overlay positioning, coordinates interactive commenting UI mode, | |
| * and tracks live DOM element positions via observers and event listeners. | |
| */ | |
| export class CommentOverlayManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> { | |
| readonly #commentManager: CommentManager.CommentManager.CommentManager; | |
| readonly #liveNodeCache = new WeakMap<CommentThread, Element>(); | |
| #observedThreads = new WeakSet<Element>(); | |
| #intersectionObserver?: IntersectionObserver; | |
| #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; | |
| #observedScrollRoots = new Set<ShadowRoot>(); | |
| #devToolsResizeObserver?: ResizeObserver; | |
| #resizeObservedElements = new WeakSet<Element>(); | |
| #mutationObserver?: MutationObserver; | |
| // Elements supplied by custom anchor resolvers move without DOM scroll or | |
| // resize events, for example when a canvas is panned. | |
| readonly #onCustomAnchorsMoved = (): void => { | |
| this.#updatePositions(true); | |
| }; | |
| #mutationRafId?: number; | |
| #rematchTimeoutId?: ReturnType<typeof setTimeout>; | |
| #cursorElement: HTMLElement|null = null; | |
| #isCreatingComment = false; | |
| constructor(commentManager: CommentManager.CommentManager.CommentManager) { | |
| super(); | |
| this.#commentManager = commentManager; | |
| this.#commentManager.addEventListener( | |
| CommentManager.CommentManager.Events.COMMENT_THREADS_CHANGED, | |
| () => { | |
| if (!this.#isCreatingComment) { | |
| this.#updatePositions(); | |
| } | |
| }, | |
| this, | |
| ); | |
| this.#commentManager.addEventListener( | |
| CommentManager.CommentManager.Events.COMMENT_MODE_CHANGED, | |
| ({data: active}) => { | |
| if (!active) { | |
| this.#clearHover(); | |
| this.clearDraftThreads(); | |
| document.body.style.cursor = ''; | |
| } | |
| }, | |
| this, | |
| ); | |
| } | |
| get commentManager(): CommentManager.CommentManager.CommentManager { | |
| return this.#commentManager; | |
| } | |
| /** | |
| * 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(true); | |
| }); | |
| } | |
| return this.#intersectionObserver; | |
| } | |
| setCommentMode(active: boolean): void { | |
| this.#commentManager.setCommentMode(active); | |
| } | |
| isCommentMode(): boolean { | |
| return this.#commentManager.isCommentMode(); | |
| } | |
| #setHoverCursor(element: Element|null): void { | |
| const previousElement = this.#cursorElement; | |
| const newElement = element instanceof HTMLElement ? element : null; | |
| if (previousElement === newElement) { | |
| return; | |
| } | |
| if (previousElement) { | |
| previousElement.style.cursor = ''; | |
| previousElement.style.removeProperty('--override-cursor'); | |
| } | |
| if (newElement) { | |
| newElement.style.cursor = COMMENT_MODE_CURSOR; | |
| newElement.style.setProperty('--override-cursor', COMMENT_MODE_CURSOR); | |
| document.body.style.cursor = COMMENT_MODE_CURSOR; | |
| } else { | |
| document.body.style.cursor = ''; | |
| } | |
| this.#cursorElement = newElement; | |
| } | |
| #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); | |
| } | |
| #clearHover(): void { | |
| this.#setHoverHighlight(null); | |
| this.#setHoverCursor(null); | |
| document.body.style.cursor = ''; | |
| } | |
| getHoverHighlight(): HoverHighlightData|null { | |
| return this.#hoverData; | |
| } | |
| getPinPositions(): PinPositionData[] { | |
| return this.#pinPositions; | |
| } | |
| getHighlight(threadId: string): HighlightRectData|undefined { | |
| return this.#highlightRects.find(h => h.id === threadId); | |
| } | |
| getAnchorElement(thread: CommentThread): Element|undefined { | |
| return this.#liveNodeCache.get(thread); | |
| } | |
| clearDraftThreads(): void { | |
| const draftThreads = this.#commentManager.getCommentThreads().filter(t => t.status === 'DRAFT'); | |
| for (const thread of draftThreads) { | |
| this.removeCommentThread(thread.id); | |
| } | |
| } | |
| handleElementClick(element: Element, options?: {clientX: number, clientY: number}): boolean { | |
| if (!this.isCommentMode() || hasDisallowedCommentAncestor(element)) { | |
| return false; | |
| } | |
| this.clearDraftThreads(); | |
| const resolved = this.#resolveAnchor(element, options); | |
| if (!resolved) { | |
| return false; | |
| } | |
| const {anchorElement: anchorEl} = resolved; | |
| const visibleRect = computeVisibleRect(anchorEl); | |
| if (!visibleRect) { | |
| return false; | |
| } | |
| const thread = this.createComment(element, undefined, {coordinates: options}); | |
| return thread !== null; | |
| } | |
| createComment( | |
| element: Element, | |
| text?: string, | |
| options?: CreateCommentOptions, | |
| ): CommentThread|null { | |
| const author = options?.author ?? 'DEVELOPER'; | |
| const isGeneratedComment = options?.isGeneratedComment; | |
| const resolved = this.#resolveAnchor(element, options?.coordinates); | |
| if (!resolved) { | |
| return null; | |
| } | |
| const {anchor, anchorElement} = resolved; | |
| let thread: CommentThread; | |
| this.#isCreatingComment = true; | |
| try { | |
| thread = this.#commentManager.createCommentThread(anchor, text, author, isGeneratedComment); | |
| } finally { | |
| this.#isCreatingComment = false; | |
| } | |
| // Non-DOM anchors (such as canvas-rendered timeline entries) have no | |
| // backing DOM node to observe. Their pins are positioned using the element | |
| // returned by their custom anchor resolver. Generated comments render no | |
| // pins. | |
| if (!thread.isGeneratedComment && isDomTrackedAnchor(anchor)) { | |
| this.#liveNodeCache.set(thread, anchorElement); | |
| const observer = this.#getIntersectionObserver(); | |
| observer.observe(anchorElement); | |
| this.#observedThreads.add(anchorElement); | |
| this.#trackElementAncestors(anchorElement); | |
| } | |
| this.#updatePositions(); | |
| return thread; | |
| } | |
| #trackElementAncestors(element: Element, activeScrollRoots?: Set<ShadowRoot>): void { | |
| if (!this.#devToolsResizeObserver || !this.#scrollListener) { | |
| return; | |
| } | |
| let current: Element|null = element; | |
| while (current) { | |
| if (!this.#resizeObservedElements.has(current)) { | |
| this.#devToolsResizeObserver.observe(current); | |
| this.#resizeObservedElements.add(current); | |
| } | |
| if (current.parentNode instanceof ShadowRoot) { | |
| activeScrollRoots?.add(current.parentNode); | |
| if (!this.#observedScrollRoots.has(current.parentNode)) { | |
| current.parentNode.addEventListener('scroll', this.#scrollListener, {capture: true, passive: true}); | |
| this.#observedScrollRoots.add(current.parentNode); | |
| } | |
| } | |
| current = current.parentElementOrShadowHost(); | |
| } | |
| } | |
| #resolveAnchor(element: Element, options?: {clientX: number, clientY: number}): | |
| {anchor: CommentAnchorSignature, anchorElement: Element}|null { | |
| let anchorElement: Element|null = null; | |
| let anchor: CommentAnchorSignature|null = null; | |
| const customResolver = getCustomAnchorResolverForElement(element); | |
| if (customResolver) { | |
| const result = customResolver.resolve(element, options); | |
| if (result) { | |
| anchor = result.anchor; | |
| anchorElement = result.anchorElement ?? element; | |
| } | |
| } else { | |
| anchorElement = resolveCommentAnchorElement(element, options); | |
| anchor = resolveCommentAnchor(element, undefined, options); | |
| } | |
| if (!anchor || !anchorElement) { | |
| return null; | |
| } | |
| return {anchor, anchorElement}; | |
| } | |
| getCommentThread(id: string): CommentThread|undefined { | |
| return this.#commentManager.getCommentThread(id); | |
| } | |
| getCommentThreads(): CommentThread[] { | |
| return this.#commentManager.getCommentThreads(); | |
| } | |
| removeCommentThread(id: string): void { | |
| const thread = this.#commentManager.getCommentThread(id); | |
| if (!thread) { | |
| return; | |
| } | |
| const el = this.#liveNodeCache.get(thread); | |
| this.#liveNodeCache.delete(thread); | |
| if (el && this.#intersectionObserver) { | |
| let isElementStillObserved = false; | |
| for (const remainingThread of this.#commentManager.getCommentThreads()) { | |
| if (remainingThread.id !== id && this.#liveNodeCache.get(remainingThread) === el) { | |
| isElementStillObserved = true; | |
| break; | |
| } | |
| } | |
| if (!isElementStillObserved) { | |
| this.#intersectionObserver.unobserve(el); | |
| this.#observedThreads.delete(el); | |
| } | |
| } | |
| this.#commentManager.removeCommentThread(id); | |
| } | |
| /** | |
| * 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 { | |
| clearClippingAncestorsCache(); | |
| // Single deep traversal shared by anchor rematching and sticky occluder clipping. | |
| const scannedElements = deepQuerySelectorAll(root, `[jslog], [${STICKY_OCCLUDER_ATTRIBUTE}]`); | |
| const jslogElements: Element[] = []; | |
| const stickyOccluders: Element[] = []; | |
| for (const el of scannedElements) { | |
| if (el.hasAttribute('jslog')) { | |
| jslogElements.push(el); | |
| } | |
| if (el.hasAttribute(STICKY_OCCLUDER_ATTRIBUTE)) { | |
| stickyOccluders.push(el); | |
| } | |
| } | |
| setStickyOccluders(stickyOccluders); | |
| const oldElements = new Set<Element>(); | |
| const newElements = new Set<Element>(); | |
| for (const thread of this.#commentManager.getCommentThreads()) { | |
| // Non-DOM anchors and generated comments do not track DOM nodes and should not be rematched here. | |
| if (thread.isGeneratedComment || !isDomTrackedAnchor(thread.anchor)) { | |
| continue; | |
| } | |
| 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(isRealtimeSync = false): void { | |
| const scrollX = window.scrollX; | |
| const scrollY = window.scrollY; | |
| const newPins: PinPositionData[] = []; | |
| const newHighlights: HighlightRectData[] = []; | |
| const elementPinCounts = new Map<Element, number>(); | |
| const rectCache = new Map<Element, DOMRect>(); | |
| const activeScrollRoots = new Set<ShadowRoot>(); | |
| for (const thread of this.#commentManager.getCommentThreads()) { | |
| // Generated comments do not render overlay pins. | |
| if (thread.isGeneratedComment) { | |
| continue; | |
| } | |
| // Non-DOM anchors (such as canvas-rendered timeline entries) are | |
| // positioned using an element supplied by their custom anchor resolver. | |
| const isDomTracked = isDomTrackedAnchor(thread.anchor); | |
| const el = isDomTracked ? this.#liveNodeCache.get(thread) || null : getCustomAnchorElement(thread.anchor); | |
| if (!el || !el.isConnected) { | |
| continue; | |
| } | |
| if (thread.anchor.editor?.filePath) { | |
| const currentFilePath = getEditorFilePath(el); | |
| if (currentFilePath && currentFilePath !== thread.anchor.editor.filePath) { | |
| continue; | |
| } | |
| } | |
| // Elements supplied by a custom anchor resolver are not observed. The | |
| // resolver reports their movement with notifyCustomAnchorsMoved(), and | |
| // removeCommentThread() only unobserves elements in #liveNodeCache. | |
| if (isDomTracked) { | |
| const observer = this.#getIntersectionObserver(); | |
| if (!this.#observedThreads.has(el)) { | |
| observer.observe(el); | |
| this.#observedThreads.add(el); | |
| } | |
| this.#trackElementAncestors(el, activeScrollRoots); | |
| } | |
| const visibleRect = computeVisibleRect(el, undefined, rectCache); | |
| if (!visibleRect) { | |
| continue; | |
| } | |
| 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 visible clipped area. | |
| newPins.push({ | |
| id: thread.id, | |
| top: scrollY + visibleRect.top - 12 + offsetY, | |
| left: scrollX + visibleRect.right - 12, | |
| visible: true, | |
| }); | |
| newHighlights.push({ | |
| id: thread.id, | |
| top: scrollY + visibleRect.top, | |
| left: scrollX + visibleRect.left, | |
| width: visibleRect.width, | |
| height: visibleRect.height, | |
| visible: true, | |
| }); | |
| } | |
| if (this.#scrollListener) { | |
| for (const root of this.#observedScrollRoots) { | |
| if (!activeScrollRoots.has(root)) { | |
| root.removeEventListener('scroll', this.#scrollListener, {capture: true}); | |
| this.#observedScrollRoots.delete(root); | |
| } | |
| } | |
| } | |
| this.#pinPositions = newPins; | |
| this.#highlightRects = newHighlights; | |
| this.dispatchEventToListeners(Events.POSITIONS_UPDATED, { | |
| pins: newPins, | |
| highlights: newHighlights, | |
| isRealtimeSync, | |
| }); | |
| } | |
| /** | |
| * 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): void { | |
| let root: Document|Element|undefined; | |
| let scrollTarget: EventTarget|undefined; | |
| let resizeTarget: Element|undefined; | |
| if (rootOrOptions && !(rootOrOptions instanceof Document) && !(rootOrOptions instanceof Element)) { | |
| root = rootOrOptions.root; | |
| scrollTarget = rootOrOptions.scrollTarget; | |
| resizeTarget = rootOrOptions.resizeTarget; | |
| } 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); | |
| this.#installScrollListener(scrollTarget); | |
| this.#installResizeObserver(resizeTarget); | |
| this.#installMutationObserver(root); | |
| addCustomAnchorsMovedListener(this.#onCustomAnchorsMoved); | |
| this.#updatePositions(); | |
| } | |
| /** | |
| * Stops and detaches all active listeners and observers without clearing comment threads. | |
| */ | |
| stop(): void { | |
| document.body.style.cursor = ''; | |
| setStickyOccluders(null); | |
| this.clearDraftThreads(); | |
| this.#removeClickListener(); | |
| this.#removeScrollListener(); | |
| this.#removeResizeObserver(); | |
| this.#removeMutationObserver(); | |
| removeCustomAnchorsMovedListener(this.#onCustomAnchorsMoved); | |
| this.#clearHover(); | |
| this.#intersectionObserver?.disconnect(); | |
| this.#intersectionObserver = undefined; | |
| this.#observedThreads = new WeakSet(); | |
| } | |
| /** | |
| * Sets up capturing click, hover, and pointer interaction listeners on the container. | |
| * | |
| * When Comment Mode is active: | |
| * - Clicks on anchorable elements open a comment thread creation draft 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): void { | |
| this.#removeClickListener(); | |
| this.#clickContainer = container; | |
| this.#clickListener = (event: Event): void => { | |
| if (!this.isCommentMode()) { | |
| return; | |
| } | |
| const composedTarget = event.composedPath()[0]; | |
| const target = (composedTarget instanceof Element) ? composedTarget : event.target; | |
| if (!(target instanceof Element)) { | |
| return; | |
| } | |
| const options = event instanceof MouseEvent ? {clientX: event.clientX, clientY: event.clientY} : undefined; | |
| if (this.handleElementClick(target, options)) { | |
| event.consume(true); | |
| } | |
| }; | |
| this.#suppressListener = (event: Event): void => { | |
| if (!this.isCommentMode()) { | |
| return; | |
| } | |
| const composedTarget = event.composedPath()[0]; | |
| const target = (composedTarget instanceof Element) ? composedTarget : event.target; | |
| if (!(target instanceof Element)) { | |
| return; | |
| } | |
| const mouseEvent = event as MouseEvent; | |
| const options = {clientX: mouseEvent.clientX, clientY: mouseEvent.clientY}; | |
| const anchorEl = resolveCommentAnchorElement(target, options); | |
| if (anchorEl) { | |
| event.consume(true); | |
| } | |
| }; | |
| this.#hoverListener = (event: Event): void => { | |
| if (!this.isCommentMode()) { | |
| this.#clearHover(); | |
| return; | |
| } | |
| const composedTarget = event.composedPath()[0]; | |
| const target = (composedTarget instanceof Element) ? composedTarget : event.target; | |
| if (!(target instanceof Element)) { | |
| this.#clearHover(); | |
| return; | |
| } | |
| const isLeaveEvent = event.type === 'mouseout' || event.type === 'mouseleave' || event.type === 'pointerout'; | |
| const customResolver = getCustomAnchorResolverForElement(target); | |
| if (customResolver) { | |
| this.#handleCustomHover(target, event, customResolver); | |
| return; | |
| } | |
| const mouseEvent = event as MouseEvent; | |
| const options = {clientX: mouseEvent.clientX, clientY: mouseEvent.clientY}; | |
| const anchorEl = resolveCommentAnchorElement(target, options); | |
| if (isLeaveEvent) { | |
| const relatedTarget = (event as MouseEvent | PointerEvent).relatedTarget; | |
| if (anchorEl && relatedTarget instanceof Node && anchorEl.isSelfOrAncestor(relatedTarget)) { | |
| event.consume(true); | |
| return; | |
| } | |
| this.#clearHover(); | |
| 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 visibleRect = computeVisibleRect(highlightTarget); | |
| if (visibleRect) { | |
| const scrollX = window.scrollX; | |
| const scrollY = window.scrollY; | |
| this.#setHoverHighlight({ | |
| top: scrollY + visibleRect.top, | |
| left: scrollX + visibleRect.left, | |
| width: visibleRect.width, | |
| height: visibleRect.height, | |
| visible: true, | |
| }); | |
| this.#setHoverCursor(highlightTarget); | |
| } else { | |
| this.#clearHover(); | |
| } | |
| event.consume(true); | |
| } else { | |
| this.#clearHover(); | |
| } | |
| }; | |
| 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; | |
| } | |
| } | |
| /** | |
| * Handles hover events for elements managed by a {@link CustomAnchorResolver} | |
| * (e.g. canvas-rendered flame charts where individual items lack backing DOM nodes). | |
| * Uses coordinate hit-testing to highlight specific entries rather than the entire element. | |
| */ | |
| #handleCustomHover(target: Element, event: Event, customResolver: CustomAnchorResolver): void { | |
| const isLeaveEvent = event.type === 'mouseout' || event.type === 'mouseleave' || event.type === 'pointerout'; | |
| if (isLeaveEvent) { | |
| this.#clearHover(); | |
| return; | |
| } | |
| const mouseEvent = event as MouseEvent; | |
| const result = customResolver.resolve( | |
| target, | |
| {clientX: mouseEvent.clientX, clientY: mouseEvent.clientY, forHover: true}, | |
| ); | |
| if (result) { | |
| if (result.highlightRect && result.highlightRect.visible !== false) { | |
| this.#setHoverHighlight({ | |
| top: result.highlightRect.top, | |
| left: result.highlightRect.left, | |
| width: result.highlightRect.width, | |
| height: result.highlightRect.height, | |
| visible: result.highlightRect.visible ?? true, | |
| }); | |
| } else { | |
| this.#setHoverHighlight(null); | |
| } | |
| this.#setHoverCursor(result.anchorElement ?? target); | |
| event.consume(true); | |
| return; | |
| } | |
| this.#clearHover(); | |
| } | |
| /** | |
| * 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; | |
| let needsTrailingUpdate = false; | |
| this.#scrollListener = (): void => { | |
| this.#clearHover(); | |
| if (this.#scrollRafId === undefined) { | |
| // Leading-edge synchronous update so pins and highlights move immediately in the current frame. | |
| this.#updatePositions(true); | |
| needsTrailingUpdate = false; | |
| this.#scrollRafId = requestAnimationFrame(() => { | |
| this.#scrollRafId = undefined; | |
| if (needsTrailingUpdate) { | |
| needsTrailingUpdate = false; | |
| this.#updatePositions(true); | |
| } | |
| }); | |
| } else { | |
| needsTrailingUpdate = true; | |
| } | |
| }; | |
| target.addEventListener('scroll', this.#scrollListener, {capture: true, passive: true}); | |
| } | |
| #removeScrollListener(): void { | |
| if (this.#scrollListener) { | |
| if (this.#scrollTarget) { | |
| this.#scrollTarget.removeEventListener('scroll', this.#scrollListener, {capture: true}); | |
| } | |
| for (const root of this.#observedScrollRoots) { | |
| root.removeEventListener('scroll', this.#scrollListener, {capture: true}); | |
| } | |
| this.#observedScrollRoots.clear(); | |
| 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.#clearHover(); | |
| this.#updatePositions(true); | |
| }); | |
| this.#devToolsResizeObserver.observe(element); | |
| this.#resizeObservedElements.add(element); | |
| } | |
| #removeResizeObserver(): void { | |
| if (this.#devToolsResizeObserver) { | |
| this.#devToolsResizeObserver.disconnect(); | |
| this.#devToolsResizeObserver = undefined; | |
| } | |
| this.#resizeObservedElements = new WeakSet(); | |
| } | |
| /** | |
| * Debounces rematching of comments across dynamic DOM updates. | |
| */ | |
| #scheduleRematch(root: Document|Element = document): void { | |
| clearClippingAncestorsCache(); | |
| if (this.#mutationRafId === undefined) { | |
| this.#mutationRafId = requestAnimationFrame(() => { | |
| this.#mutationRafId = undefined; | |
| this.#updatePositions(true); | |
| }); | |
| } | |
| 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(records => { | |
| const hasExternalMutation = records.some(record => { | |
| const target = record.target; | |
| const element = target instanceof Element ? target : target.parentElement; | |
| return !(element && element.closest('.comments-overlay-container')); | |
| }); | |
| if (hasExternalMutation) { | |
| this.#scheduleRematch(root); | |
| } | |
| }); | |
| const targetNode = root instanceof Document ? (root.body || root.documentElement) : root; | |
| this.#mutationObserver.observe(targetNode, { | |
| childList: true, | |
| subtree: true, | |
| attributeFilter: [ | |
| 'jslog', | |
| 'data-network-request-id', | |
| 'data-backend-node-id', | |
| 'data-target-id', | |
| 'aria-expanded', | |
| 'data-file-path', | |
| STICKY_OCCLUDER_ATTRIBUTE, | |
| ], | |
| }); | |
| } | |
| #removeMutationObserver(): void { | |
| if (this.#mutationObserver) { | |
| this.#mutationObserver.disconnect(); | |
| this.#mutationObserver = undefined; | |
| } | |
| if (this.#mutationRafId !== undefined) { | |
| cancelAnimationFrame(this.#mutationRafId); | |
| this.#mutationRafId = 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.#commentManager.clear(); | |
| this.stop(); | |
| this.#pinPositions = []; | |
| this.#highlightRects = []; | |
| this.#updatePositions(); | |
| } | |
| } |