| // 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 CommentManager from '../../models/comment_manager/comment_manager.js'; | |
| import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; | |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; | |
| export type EditorAnchorSignature = CommentManager.CommentManager.EditorAnchorSignature; | |
| export type TimelineAnchorSignature = CommentManager.CommentManager.TimelineAnchorSignature; | |
| export type CommentAnchorSignature = CommentManager.CommentManager.CommentAnchorSignature; | |
| export type CommentThread = CommentManager.CommentManager.CommentThread; | |
| const DISALLOWED_COMMENT_TARGETS = new Set<number>([ | |
| // Top-level containers & layout structures | |
| VisualLogging.VisualElements.Panel, | |
| VisualLogging.VisualElements.Drawer, | |
| VisualLogging.VisualElements.Pane, | |
| VisualLogging.VisualElements.Tree, | |
| VisualLogging.VisualElements.PanelTabHeader, | |
| VisualLogging.VisualElements.Resizer, | |
| VisualLogging.VisualElements.Menu, | |
| VisualLogging.VisualElements.Popover, | |
| VisualLogging.VisualElements.Dialog, | |
| // Minor controls and toolbars | |
| VisualLogging.VisualElements.Link, | |
| VisualLogging.VisualElements.Section, | |
| VisualLogging.VisualElements.Counter, | |
| VisualLogging.VisualElements.Action, | |
| VisualLogging.VisualElements.Toggle, | |
| VisualLogging.VisualElements.Close, | |
| VisualLogging.VisualElements.Expand, | |
| VisualLogging.VisualElements.ToggleSubpane, | |
| VisualLogging.VisualElements.Toolbar, | |
| VisualLogging.VisualElements.DropDown, | |
| VisualLogging.VisualElements.FilterDropdown, | |
| VisualLogging.VisualElements.TextField, | |
| ]); | |
| const DISALLOWED_COMMENT_ANCESTORS = new Set<number>([ | |
| VisualLogging.VisualElements.Toolbar, | |
| VisualLogging.VisualElements.Menu, | |
| VisualLogging.VisualElements.Popover, | |
| VisualLogging.VisualElements.Dialog, | |
| VisualLogging.VisualElements.PanelTabHeader, | |
| ]); | |
| /** | |
| * Finds the closest ancestor (or the element itself) matching a CSS selector, | |
| * traversing across Shadow DOM boundaries (shadow root boundaries to shadow hosts). | |
| * | |
| * @param element The starting element for traversal. | |
| * @param selector The CSS selector to match against. | |
| * @returns The first matching Element or null if none is found. | |
| */ | |
| export function closestAcrossShadow(element: Element, selector: string): Element|null { | |
| let current: Element|null = element; | |
| while (current) { | |
| if (current.matches(selector)) { | |
| return current; | |
| } | |
| current = current.parentElementOrShadowHost(); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Checks whether an element is a CodeMirror editor container (`.cm-editor`). | |
| * | |
| * @param element The element to check. | |
| * @returns True if the element has the `.cm-editor` class; otherwise false. | |
| */ | |
| function isCodeMirrorEditor(element: Element): boolean { | |
| return element.classList.contains('cm-editor'); | |
| } | |
| /** | |
| * Resolves the file path attribute for a CodeMirror editor element. | |
| * | |
| * @param element The editor element to check. | |
| * @returns The file path string or undefined if not found. | |
| */ | |
| export function getEditorFilePath(element: Element): string|undefined { | |
| return element.getAttribute('data-file-path') ?? undefined; | |
| } | |
| /** | |
| * Determines whether an anchor is backed by a tracked DOM element. | |
| * | |
| * Canvas-rendered anchors (such as Performance panel timeline entries) do not have | |
| * individual DOM nodes and manage their own overlays in canvas coordinates. | |
| * These anchors return false and bypass DOM-level node caching, rematching, | |
| * and IntersectionObserver tracking. | |
| * | |
| * @param anchor The comment anchor signature to check. | |
| * @returns True if the anchor corresponds to a DOM-tracked element; otherwise false. | |
| */ | |
| export function isDomTrackedAnchor(anchor: CommentAnchorSignature): boolean { | |
| return !anchor.timeline; | |
| } | |
| /** | |
| * Result returned by a {@link CustomAnchorResolver} representing an anchor | |
| * within a specialized or canvas-rendered view. | |
| */ | |
| export interface CustomAnchorResult { | |
| /** The anchor signature representing the commented item. */ | |
| anchor: CommentAnchorSignature; | |
| /** The DOM element acting as the visual host (e.g. the canvas element). */ | |
| anchorElement?: Element; | |
| /** Optional bounding box within the page for the hover or highlight overlay. */ | |
| highlightRect?: {top: number, left: number, width: number, height: number, visible?: boolean}; | |
| } | |
| /** | |
| * Extension point allowing views that render custom content (such as canvas-based | |
| * flame charts) to provide custom anchor resolution for comments without direct DOM nodes. | |
| * | |
| * The shared comments overlay draws the pin, popup and highlight for these | |
| * anchors in its own layer, as it does for DOM anchors. The view supplies the | |
| * parts that depend on how it renders: | |
| * - `resolve()` maps a pointer position to an anchor when a comment is created. | |
| * - `getAnchorElement()` returns a DOM element that the view keeps over the | |
| * anchor on screen. The element can be invisible. CommentOverlayManager | |
| * positions the pin from the element's visible rect. | |
| * - The view calls `notifyCustomAnchorsMoved()` whenever it moves or hides | |
| * these elements, because CommentOverlayManager does not observe them. | |
| * | |
| * For example, the Performance panel returns an invisible timeline overlay box | |
| * that it keeps over each commented entry, and notifies after every overlays | |
| * update, such as when the flame chart is panned. | |
| */ | |
| export interface CustomAnchorResolver { | |
| /** | |
| * Determines whether this resolver can handle anchors for the target element. | |
| * | |
| * @param element The element currently hovered or clicked. | |
| * @returns True if this resolver manages anchors within the given element. | |
| */ | |
| matches(element: Element): boolean; | |
| /** | |
| * Resolves an anchor signature and highlight bounds for a point within the element. | |
| * | |
| * @param element The target element matched by this resolver. | |
| * @param options Pointer coordinates and a flag indicating if resolution is for a hover preview. | |
| * @returns The resolved anchor result, or null if no anchor is present at the specified location. | |
| */ | |
| resolve(element: Element, options?: {clientX: number, clientY: number, forHover?: boolean}): CustomAnchorResult|null; | |
| /** | |
| * Returns a positioned DOM element that tracks the on-screen bounds of a | |
| * non-DOM anchor, or null if the anchor is not handled by this resolver or | |
| * is not currently visible. | |
| */ | |
| getAnchorElement(anchor: CommentAnchorSignature): Element|null; | |
| } | |
| const customAnchorResolvers = new Set<CustomAnchorResolver>(); | |
| const customAnchorsMovedListeners = new Set<() => void>(); | |
| /** | |
| * Registers a custom anchor resolver. Usually called when a view becomes visible | |
| * (e.g. inside `wasShown()`). | |
| * | |
| * @param resolver The custom anchor resolver to register. | |
| */ | |
| export function registerCustomAnchorResolver(resolver: CustomAnchorResolver): void { | |
| customAnchorResolvers.add(resolver); | |
| } | |
| /** | |
| * Unregisters a custom anchor resolver. Usually called when a view hides | |
| * (e.g. inside `willHide()`). | |
| * | |
| * @param resolver The custom anchor resolver to unregister. | |
| */ | |
| export function unregisterCustomAnchorResolver(resolver: CustomAnchorResolver): void { | |
| customAnchorResolvers.delete(resolver); | |
| } | |
| /** | |
| * Clears all registered custom anchor resolvers. Test-only helper. | |
| */ | |
| export function clearCustomAnchorResolversForTest(): void { | |
| customAnchorResolvers.clear(); | |
| } | |
| /** | |
| * Finds the first registered custom anchor resolver that matches the given element. | |
| * | |
| * @param element The element to check. | |
| * @returns The matching resolver, or null if no resolver matches. | |
| */ | |
| export function getCustomAnchorResolverForElement(element: Element): CustomAnchorResolver|null { | |
| for (const resolver of customAnchorResolvers) { | |
| if (resolver.matches(element)) { | |
| return resolver; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Returns the positioned element for a non-DOM anchor from the first | |
| * registered resolver that provides one, or null if no resolver handles the | |
| * anchor or the anchor is not visible. | |
| */ | |
| export function getCustomAnchorElement(anchor: CommentAnchorSignature): Element|null { | |
| for (const resolver of customAnchorResolvers) { | |
| const element = resolver.getAnchorElement(anchor); | |
| if (element) { | |
| return element; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Requests that comment pins on non-DOM anchors are repositioned. The | |
| * elements returned by `getAnchorElement()` are not observed for DOM scroll, | |
| * resize or intersection changes, so this must be called whenever they move | |
| * or change visibility (for example, when a canvas is panned). Resolvers do | |
| * not hold a reference to a CommentOverlayManager, so every registered | |
| * listener is notified. | |
| */ | |
| export function notifyCustomAnchorsMoved(): void { | |
| for (const listener of customAnchorsMovedListeners) { | |
| listener(); | |
| } | |
| } | |
| /** | |
| * Registers a listener for `notifyCustomAnchorsMoved()`. CommentOverlayManager | |
| * uses it to reposition pins on non-DOM anchors. | |
| */ | |
| export function addCustomAnchorsMovedListener(listener: () => void): void { | |
| customAnchorsMovedListeners.add(listener); | |
| } | |
| /** | |
| * Unregisters a listener added with `addCustomAnchorsMovedListener()`. | |
| */ | |
| export function removeCustomAnchorsMovedListener(listener: () => void): void { | |
| customAnchorsMovedListeners.delete(listener); | |
| } | |
| /** | |
| * Checks whether an element contains non-empty text content (after trimming whitespace), | |
| * including text from any nested shadow roots. | |
| * | |
| * @param element The element to check. | |
| * @returns True if the element contains non-empty text; otherwise false. | |
| */ | |
| export function isNonEmptyItem(element: Element): boolean { | |
| return element.deepTextContent().trim().length > 0; | |
| } | |
| function getElementLoggingConfig(element: Element): VisualLogging.LoggingConfig|null { | |
| if (!VisualLogging.needsLogging(element)) { | |
| return null; | |
| } | |
| try { | |
| return VisualLogging.getLoggingConfig(element); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * Determines whether an element or any of its ancestors across shadow boundaries | |
| * is a container where commenting is disallowed | |
| * | |
| * @param element The element to check. | |
| * @returns True if the element or an ancestor is a disallowed comment ancestor; otherwise false. | |
| */ | |
| export function hasDisallowedCommentAncestor(element: Element): boolean { | |
| let current: Element|null = element; | |
| while (current) { | |
| if (current.classList.contains('comments-overlay-container')) { | |
| return true; | |
| } | |
| const config = getElementLoggingConfig(current); | |
| if (config && (DISALLOWED_COMMENT_ANCESTORS.has(config.ve) || config.context === 'comments')) { | |
| return true; | |
| } | |
| current = current.parentElementOrShadowHost(); | |
| } | |
| return false; | |
| } | |
| interface CodeMirrorLineInfo { | |
| lineNumber: number; | |
| textSignature: string; | |
| } | |
| /** | |
| * Resolves line information (1-based line number and trimmed line text) for an element inside a CodeMirror editor. | |
| * | |
| * Uses the underlying `CodeMirror.EditorView` and `EditorState.doc` data model directly via | |
| * `EditorView.findFromDOM()`. This avoids relying on virtualized DOM nodes (`.cm-line` elements) | |
| * which only exist for the currently visible viewport. | |
| * | |
| * @param element The source DOM element inside or on the editor. | |
| * @returns An object containing the 1-based line number and line text, or null if unresolvable or empty. | |
| */ | |
| function resolveCodeMirrorLineInfo(element: Element): CodeMirrorLineInfo|null { | |
| const cmEditor = element.closest('.cm-editor') as HTMLElement | null; | |
| if (!cmEditor) { | |
| return null; | |
| } | |
| const view = CodeMirror.EditorView.findFromDOM(cmEditor); | |
| if (!view) { | |
| throw new Error('Could not find CodeMirror EditorView from .cm-editor element'); | |
| } | |
| const doc = view.state.doc; | |
| // 1. Gutter element clicked (line numbers) | |
| const gutterEl = element.closest('.cm-gutterElement'); | |
| if (gutterEl) { | |
| const rawText = gutterEl.textContent?.trim() || ''; | |
| if (rawText.length > 0 && /^\d+$/.test(rawText)) { | |
| const lineNum = parseInt(rawText, 10); | |
| if (lineNum > 0 && lineNum <= doc.lines) { | |
| const line = doc.line(lineNum); | |
| const textSignature = line.text.trim(); | |
| return textSignature ? {lineNumber: line.number, textSignature} : null; | |
| } | |
| } | |
| return null; | |
| } | |
| // 2. Content / line / token clicked: use line element directly or escalate to closest .cm-line | |
| const cmLine = element.classList.contains('cm-line') ? element : element.closest('.cm-line'); | |
| if (cmLine) { | |
| try { | |
| const pos = view.posAtDOM(cmLine); | |
| const line = doc.lineAt(pos); | |
| const textSignature = line.text.trim(); | |
| return textSignature ? {lineNumber: line.number, textSignature} : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Resolves an arbitrary clicked or targeted DOM element to its appropriate semantic comment anchor element. | |
| * | |
| * Traversal hierarchy: | |
| * 1. Checks if any ancestor is in DISALLOWED_COMMENT_ANCESTORS (returns null if so). | |
| * 2. Escalates CodeMirror line/gutter elements to .cm-editor (only if the clicked line is non-empty). | |
| * 3. Checks for domain IDs (`data-network-request-id` or `data-backend-node-id`) across shadow boundaries, | |
| * returning the owning domain element. | |
| * 4. Escalates minor controls / sub-elements up to semantic containers (e.g., TableRow, TreeItem). | |
| * 5. Falls back to the nearest visual logging element if no semantic container is found, | |
| * excluding top-level containers and minor controls. | |
| * | |
| * @param element The source DOM element to resolve. | |
| * @returns The resolved semantic anchor Element, or null if unresolvable/empty/excluded. | |
| */ | |
| export function resolveCommentAnchorElement( | |
| element: Element, options?: {clientX: number, clientY: number, forHover?: boolean}): Element|null { | |
| if (hasDisallowedCommentAncestor(element)) { | |
| return null; | |
| } | |
| const customResolver = getCustomAnchorResolverForElement(element); | |
| if (customResolver) { | |
| const result = customResolver.resolve(element, options); | |
| if (!result) { | |
| return null; | |
| } | |
| return result.anchorElement ?? element; | |
| } | |
| // CodeMirror internal lines, gutters, and content live inside .cm-editor. | |
| // We only allow commenting on non-empty lines within the editor; the whole editor | |
| // container is never a valid comment target. | |
| const cmEditor = element.closest('.cm-editor'); | |
| if (cmEditor) { | |
| const lineInfo = resolveCodeMirrorLineInfo(element); | |
| if (!lineInfo) { | |
| return null; | |
| } | |
| return cmEditor; | |
| } | |
| const domainElement = closestAcrossShadow(element, '[data-network-request-id], [data-backend-node-id]'); | |
| if (domainElement) { | |
| return isNonEmptyItem(domainElement) ? domainElement : null; | |
| } | |
| let target: Element|null = element; | |
| let fallbackCandidate: Element|null = null; | |
| while (target) { | |
| const config = getElementLoggingConfig(target); | |
| if (config) { | |
| if (config.ve === VisualLogging.VisualElements.TableRow || config.ve === VisualLogging.VisualElements.TreeItem) { | |
| return isNonEmptyItem(target) ? target : null; | |
| } | |
| if (!fallbackCandidate && !DISALLOWED_COMMENT_TARGETS.has(config.ve)) { | |
| fallbackCandidate = target; | |
| } | |
| } | |
| target = target.parentElementOrShadowHost(); | |
| } | |
| if (fallbackCandidate && isNonEmptyItem(fallbackCandidate)) { | |
| return fallbackCandidate; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Extracts the trailing Visual Element type name from a full visual logging path. | |
| * Used as a fast pre-filter optimization before calculating full ancestor VE paths. | |
| * | |
| * @param vePath The full visual logging path string (e.g. "Panel: elements > TreeItem: rule"). | |
| * @returns The trailing VE type name (e.g. "TreeItem"). | |
| */ | |
| export function extractVeName(vePath: string): string { | |
| return vePath.split(' > ').pop()?.split(':')[0]?.trim() || ''; | |
| } | |
| /** | |
| * Extracts the top-level panel ID from a visual logging path if present. | |
| * For example: | |
| * - "Panel: timeline > FlameChart: main" -> "timeline" | |
| * - "Panel: elements > Pane: styles" -> "elements" | |
| * | |
| * @param vePath The visual logging path string. | |
| * @returns The panel ID string, or undefined if no Panel component is found. | |
| */ | |
| export function extractPanelId(vePath?: string): string|undefined { | |
| if (!vePath) { | |
| return undefined; | |
| } | |
| const match = vePath.match(/^Panel:\s*([a-zA-Z0-9_-]+)/); | |
| return match ? match[1] : undefined; | |
| } | |
| /** | |
| * Checks if an element matches the given visual logging path. | |
| * | |
| * @param element The DOM element to test. | |
| * @param vePath The expected visual logging path. | |
| * @param targetVeName Optional trailing VE name used as a fast pre-filter optimization to reject | |
| * non-matching elements without performing an expensive full DOM ancestor traversal in `VisualLogging.getVePath`. | |
| * @returns True if the element's VE path matches vePath; otherwise false. | |
| */ | |
| export function matchesVePath(element: Element, vePath: string, targetVeName: string = extractVeName(vePath)): boolean { | |
| if (!VisualLogging.needsLogging(element)) { | |
| return false; | |
| } | |
| // Optimization: fast pre-filter on local jslog attribute to avoid full getVePath() ancestor tree traversal | |
| const jslog = element.getAttribute('jslog'); | |
| if (targetVeName && jslog) { | |
| const match = jslog.trim().match(/^([a-zA-Z0-9_-]+)/); | |
| if (!match || match[1] !== targetVeName) { | |
| return false; | |
| } | |
| } | |
| // Authoritative check | |
| return VisualLogging.getVePath(element) === vePath; | |
| } | |
| /** | |
| * Computes the 0-indexed position of an element among all elements sharing the same visual logging path | |
| * in document order across light and shadow DOM trees. | |
| * | |
| * @param element The target element. | |
| * @param vePath The visual logging path to match. | |
| * @param root The root Document or Element to search within (defaults to element's ownerDocument or document). | |
| * @returns The 0-based index among VE siblings. | |
| */ | |
| export function getSiblingIndex(element: Element, vePath: string, | |
| root: Document|Element = element.ownerDocument || document): number { | |
| const targetVeName = extractVeName(vePath); | |
| const allJslog = deepQuerySelectorAll(root, '[jslog]'); | |
| let index = 0; | |
| for (const el of allJslog) { | |
| if (el === element) { | |
| return index; | |
| } | |
| if (matchesVePath(el, vePath, targetVeName)) { | |
| index++; | |
| } | |
| } | |
| return index; | |
| } | |
| /** | |
| * Checks whether a CodeMirror editor matches a specific line number and text signature. | |
| * | |
| * Uses `EditorView.findFromDOM()` to inspect the document model in memory rather than | |
| * querying virtualized DOM line nodes. | |
| * | |
| * @param editor The `.cm-editor` element. | |
| * @param editorLineNumber The expected 1-based line number. | |
| * @param textSignature The expected text content of the line. | |
| * @returns True if the editor contains the line matching both line number and text signature; otherwise false. | |
| */ | |
| function checkCodeMirrorLineMatch(editor: Element, editorLineNumber: number, textSignature: string): boolean { | |
| const view = CodeMirror.EditorView.findFromDOM(editor as HTMLElement); | |
| if (!view) { | |
| return false; | |
| } | |
| const doc = view.state.doc; | |
| if (editorLineNumber <= 0 || editorLineNumber > doc.lines) { | |
| return false; | |
| } | |
| const line = doc.line(editorLineNumber); | |
| return line.text.trim() === textSignature; | |
| } | |
| /** | |
| * Resolves a DOM element to a robust, serializable `CommentAnchorSignature`. | |
| * | |
| * The signature captures visual logging paths, text content, sibling index disambiguation, | |
| * domain IDs (`networkRequestId`, `backendNodeId`), and CodeMirror editor coordinates to allow | |
| * resilient rematching across DOM re-renders, filtering, and DevTools sessions. | |
| * | |
| * @param element The source DOM element to resolve into an anchor signature. | |
| * @param root Optional root Document or Element to search within for sibling index calculation. | |
| * @returns The resolved CommentAnchorSignature, or null if unresolvable. | |
| */ | |
| export function resolveCommentAnchor( | |
| element: Element, root: Document|Element = element.ownerDocument || document, | |
| options?: {clientX: number, clientY: number, forHover?: boolean}): CommentAnchorSignature|null { | |
| const customResolver = getCustomAnchorResolverForElement(element); | |
| if (customResolver) { | |
| const result = customResolver.resolve(element, options); | |
| return result ? result.anchor : null; | |
| } | |
| const target = resolveCommentAnchorElement(element, options); | |
| if (!target) { | |
| return null; | |
| } | |
| // 1. Construct vePath | |
| const vePath = VisualLogging.getVePath(target); | |
| if (!vePath) { | |
| return null; | |
| } | |
| // 2. Extract text signature and parent text signature | |
| const isEditorTarget = isCodeMirrorEditor(target); | |
| let textSignature: string; | |
| let parentTextSignature: string|undefined; | |
| let editor: EditorAnchorSignature|undefined; | |
| if (isEditorTarget) { | |
| const lineInfo = resolveCodeMirrorLineInfo(element); | |
| if (!lineInfo) { | |
| return null; | |
| } | |
| textSignature = lineInfo.textSignature; | |
| const filePath = getEditorFilePath(target); | |
| editor = {lineNumber: lineInfo.lineNumber, filePath}; | |
| } else { | |
| textSignature = target.deepTextContent(); | |
| const parentEl = target.parentElementOrShadowHost(); | |
| parentTextSignature = parentEl ? parentEl.deepTextContent() : undefined; | |
| } | |
| // 3. Calculate sibling index among elements with same vePath in document order | |
| const siblingIndex = getSiblingIndex(target, vePath, root); | |
| // 4. Extract optional domain IDs directly from the resolved target element | |
| const networkRequestId = target.getAttribute('data-network-request-id') ?? undefined; | |
| const backendNodeIdStr = target.getAttribute('data-backend-node-id'); | |
| const backendNodeId = backendNodeIdStr ? Number(backendNodeIdStr) : undefined; | |
| const targetId = target.getAttribute('data-target-id') ?? undefined; | |
| const node = (backendNodeId !== undefined && targetId !== undefined) ? {backendNodeId, targetId} : undefined; | |
| return { | |
| vePath, | |
| textSignature, | |
| parentTextSignature, | |
| siblingIndex, | |
| networkRequestId, | |
| node, | |
| editor, | |
| }; | |
| } | |
| /** | |
| * Searches a document or element tree (recursively traversing all Shadow DOM roots) | |
| * and returns all matching descendant elements up to the specified limit in document order. | |
| * | |
| * Note: The root container itself is not matched against selector; only descendants are returned. | |
| * | |
| * @param root The root Document or Element to search from. | |
| * @param selector The CSS selector to match against. | |
| * @param limit Maximum number of matching elements to return (defaults to Infinity). | |
| * @returns Array of matching Elements in document order. | |
| */ | |
| export function deepQuerySelectorAll(root: Document|Element, selector: string, limit: number = Infinity): Element[] { | |
| const results: Element[] = []; | |
| if (limit <= 0 || Number.isNaN(limit)) { | |
| return results; | |
| } | |
| function collectFromContainer(container: Document|Element|ShadowRoot): boolean { | |
| if (container instanceof Element && container.shadowRoot) { | |
| if (collectFromContainer(container.shadowRoot)) { | |
| return true; | |
| } | |
| } | |
| let child = container.firstElementChild; | |
| while (child) { | |
| if (child.matches(selector)) { | |
| results.push(child); | |
| if (results.length >= limit) { | |
| return true; | |
| } | |
| } | |
| if (collectFromContainer(child)) { | |
| return true; | |
| } | |
| child = child.nextElementSibling; | |
| } | |
| return false; | |
| } | |
| collectFromContainer(root); | |
| return results; | |
| } | |
| /** | |
| * Finds the first matching descendant element across light and shadow DOM trees. | |
| * | |
| * @param root The root Document or Element to search from. | |
| * @param selector The CSS selector to match against. | |
| * @returns The first matching Element or null if none is found. | |
| */ | |
| export function deepQuerySelector(root: Document|Element, selector: string): Element|null { | |
| return deepQuerySelectorAll(root, selector, 1)[0] ?? null; | |
| } | |
| /** | |
| * Rematches a stored comment thread to its live corresponding DOM element. | |
| * | |
| * Matching pipeline: | |
| * 1. Primary fast-path: Query by domain IDs (`networkRequestId` or `backendNodeId`) across shadow roots. | |
| * 2. CodeMirror editor line match: Match editor and line number/text, scoped by `filePath` if present. | |
| * 3. Visual logging path fallback: Find all candidate elements matching `vePath`. | |
| * 4. Text content refinement: Filter candidates by `textSignature` and `parentTextSignature`. | |
| * 5. Sibling index disambiguation: Match exact sibling position when multiple candidates exist. | |
| * | |
| * @param comment The comment thread containing the anchor signature to rematch. | |
| * @param root The root Document or Element to search within (defaults to document). | |
| * @param cachedJslogElements Optional pre-collected list of `[jslog]` elements for performance. | |
| * @returns The rematched live Element, or null if no match is found. | |
| */ | |
| export function rematchCommentAnchor(comment: CommentThread, root: Document|Element = document, | |
| cachedJslogElements?: Element[]): Element|null { | |
| const {anchor} = comment; | |
| // Step 1: Primary domain ID fast-path (shadow-piercing data attribute query) | |
| if (anchor.networkRequestId) { | |
| return deepQuerySelector(root, `[data-network-request-id="${CSS.escape(anchor.networkRequestId)}"]`); | |
| } | |
| if (anchor.node) { | |
| return deepQuerySelector( | |
| root, | |
| `[data-backend-node-id="${CSS.escape(String(anchor.node.backendNodeId))}"][data-target-id="${ | |
| CSS.escape(anchor.node.targetId)}"]`); | |
| } | |
| if (anchor.editor) { | |
| const {lineNumber, filePath} = anchor.editor; | |
| const cmEditors = deepQuerySelectorAll(root, '.cm-editor'); | |
| const matchingEditors = cmEditors.filter(cmEditor => { | |
| if (filePath !== undefined && getEditorFilePath(cmEditor) !== filePath) { | |
| return false; | |
| } | |
| return VisualLogging.getVePath(cmEditor) === anchor.vePath; | |
| }); | |
| for (const cmEditor of matchingEditors) { | |
| if (anchor.textSignature !== undefined && checkCodeMirrorLineMatch(cmEditor, lineNumber, anchor.textSignature)) { | |
| return cmEditor; | |
| } | |
| } | |
| return matchingEditors[0] ?? null; | |
| } | |
| // Step 2: VE path and deepTextContent fallback (for non-ID controls and console logs) | |
| const targetVeName = extractVeName(anchor.vePath); | |
| // Optimization: use cachedJslogElements if provided to avoid re-scanning the entire DOM | |
| const allJslog = cachedJslogElements || deepQuerySelectorAll(root, '[jslog]'); | |
| const candidates = allJslog.filter(el => matchesVePath(el, anchor.vePath, targetVeName)); | |
| if (candidates.length === 0) { | |
| return null; | |
| } | |
| if (candidates.length === 1) { | |
| return candidates[0]; | |
| } | |
| let candidateList = candidates; | |
| // Filter by textSignature | |
| if (anchor.textSignature !== undefined) { | |
| const textMatches = candidateList.filter(el => el.deepTextContent() === anchor.textSignature); | |
| if (textMatches.length > 0) { | |
| candidateList = textMatches; | |
| } | |
| } | |
| if (candidateList.length === 1) { | |
| return candidateList[0]; | |
| } | |
| // Filter by parentTextSignature | |
| if (anchor.parentTextSignature !== undefined) { | |
| const parentMatches = candidateList.filter(el => { | |
| const parentEl = el.parentElementOrShadowHost(); | |
| return parentEl?.deepTextContent() === anchor.parentTextSignature; | |
| }); | |
| if (parentMatches.length > 0) { | |
| candidateList = parentMatches; | |
| } | |
| } | |
| if (candidateList.length === 1) { | |
| return candidateList[0]; | |
| } | |
| // Disambiguate with siblingIndex | |
| if (anchor.siblingIndex !== undefined) { | |
| const siblingMatches = candidateList.filter(el => candidates.indexOf(el) === anchor.siblingIndex); | |
| if (siblingMatches.length > 0) { | |
| candidateList = siblingMatches; | |
| } | |
| } | |
| // Step 3: Single-Element Canonicalization (return the first matching node in document order) | |
| return candidateList[0] || null; | |
| } | |
| export interface VisibleRect { | |
| left: number; | |
| top: number; | |
| right: number; | |
| bottom: number; | |
| width: number; | |
| height: number; | |
| } | |
| function isClippingOverflow(overflow: string): boolean { | |
| return overflow === 'hidden' || overflow === 'auto' || overflow === 'scroll' || overflow === 'clip'; | |
| } | |
| interface ClippingAncestor { | |
| element: Element; | |
| occluders: Element[]; | |
| clipsX: boolean; | |
| clipsY: boolean; | |
| } | |
| /** | |
| * Attribute that marks an element as a sticky occluder: an element that sticks | |
| * to its scroll container (e.g. a `position: sticky` table header or toolbar) | |
| * and paints over scrolled content. Comment overlays are clipped against it. | |
| * Components set it directly so they don't need to depend on this module. | |
| */ | |
| export const STICKY_OCCLUDER_ATTRIBUTE = 'data-sticky-occluder'; | |
| /** | |
| * Sticky occluders collected by the caller's DOM scan (see `setStickyOccluders()`), | |
| * or null to let the resolver scan the document itself. | |
| */ | |
| let stickyOccluders: Element[]|null = null; | |
| let occludersByScrollerCache: Map<Element, Element[]>|null = null; | |
| let clippingAncestorsCache = new WeakMap<Element, ClippingAncestor[]>(); | |
| /** | |
| * Provides the sticky occluders found by a DOM scan the caller already runs, so | |
| * the resolver does not walk the whole DOM again. CommentOverlayManager collects | |
| * them in the same pass as `[jslog]` elements during rematching. | |
| * | |
| * @param elements Elements with the sticky occluder attribute, or null to make | |
| * the resolver scan the document itself. | |
| */ | |
| export function setStickyOccluders(elements: Element[]|null): void { | |
| stickyOccluders = elements; | |
| clearClippingAncestorsCache(); | |
| } | |
| function isScrollingOverflow(overflow: string): boolean { | |
| return overflow === 'hidden' || overflow === 'auto' || overflow === 'scroll'; | |
| } | |
| function getStickyScroller(occluder: Element, doc: Document, win: Window): Element|null { | |
| let current = occluder.parentElementOrShadowHost(); | |
| while (current && current !== doc.documentElement && current !== doc.body) { | |
| const currentStyle = win.getComputedStyle(current); | |
| if (isScrollingOverflow(currentStyle.overflowX) || isScrollingOverflow(currentStyle.overflowY)) { | |
| return current; | |
| } | |
| current = current.parentElementOrShadowHost(); | |
| } | |
| return null; | |
| } | |
| function buildOccludersByScrollerMap(doc: Document, win: Window): Map<Element, Element[]> { | |
| if (occludersByScrollerCache) { | |
| return occludersByScrollerCache; | |
| } | |
| const result = new Map<Element, Element[]>(); | |
| const occluders = stickyOccluders ?? deepQuerySelectorAll(doc, `[${STICKY_OCCLUDER_ATTRIBUTE}]`); | |
| for (const occ of occluders) { | |
| // The provided list can be up to one rematch behind the DOM. | |
| if (!occ.isConnected) { | |
| continue; | |
| } | |
| const scroller = getStickyScroller(occ, doc, win); | |
| if (!scroller) { | |
| continue; | |
| } | |
| const existingOccluders = result.get(scroller); | |
| if (existingOccluders) { | |
| existingOccluders.push(occ); | |
| } else { | |
| result.set(scroller, [occ]); | |
| } | |
| } | |
| occludersByScrollerCache = result; | |
| return result; | |
| } | |
| /** | |
| * Clears the cached clipping ancestor chains for elements. | |
| * Called when DOM mutations or comment rematches occur. | |
| */ | |
| export function clearClippingAncestorsCache(): void { | |
| clippingAncestorsCache = new WeakMap<Element, ClippingAncestor[]>(); | |
| occludersByScrollerCache = null; | |
| } | |
| function getClippingAncestors(element: Element, doc: Document, win: Window): ClippingAncestor[] { | |
| const cached = clippingAncestorsCache.get(element); | |
| if (cached && cached.every(item => item.element.isConnected && item.occluders.every(occ => occ.isConnected))) { | |
| return cached; | |
| } | |
| const ancestors: ClippingAncestor[] = []; | |
| const allAncestors = new Set<Element>(); | |
| let current = element.parentElementOrShadowHost(); | |
| while (current && current !== doc.documentElement && current !== doc.body) { | |
| allAncestors.add(current); | |
| const style = win.getComputedStyle(current); | |
| const clipsX = isClippingOverflow(style.overflowX); | |
| const clipsY = isClippingOverflow(style.overflowY); | |
| if (clipsX || clipsY) { | |
| ancestors.push({element: current, clipsX, clipsY, occluders: []}); | |
| } | |
| current = current.parentElementOrShadowHost(); | |
| } | |
| const occludersByScroller = buildOccludersByScrollerMap(doc, win); | |
| for (const ancestor of ancestors) { | |
| const occluders = occludersByScroller.get(ancestor.element); | |
| if (!occluders) { | |
| continue; | |
| } | |
| // An anchor that is the occluder or sits inside it (e.g. a header cell) is painted by it, not covered. | |
| ancestor.occluders = occluders.filter(occ => occ !== element && !allAncestors.has(occ)); | |
| } | |
| clippingAncestorsCache.set(element, ancestors); | |
| return ancestors; | |
| } | |
| interface Bounds { | |
| left: number; | |
| right: number; | |
| top: number; | |
| bottom: number; | |
| } | |
| function getCachedRect(element: Element, rectCache?: Map<Element, DOMRect>): DOMRect { | |
| let rect = rectCache?.get(element); | |
| if (!rect) { | |
| rect = element.getBoundingClientRect(); | |
| rectCache?.set(element, rect); | |
| } | |
| return rect; | |
| } | |
| /** | |
| * Trims visible bounds by the sticky occluders of one scroll container. | |
| * | |
| * A sticky occluder is an element marked with STICKY_OCCLUDER_ATTRIBUTE that sticks | |
| * to the edge of its scroll container (e.g. a table header or toolbar) and paints | |
| * over content scrolled underneath it. That content is not visible, even though it | |
| * is still inside the scroll container's bounds. | |
| * | |
| * - Covers the top edge: the bounds start at the occluder's bottom. | |
| * - Covers the bottom edge: the bounds end at the occluder's top. | |
| * - Covers the whole height: returns null. | |
| * - Sits strictly inside the bounds: ignored, since a rect cannot have a hole. | |
| * | |
| * Occluders with no size or no horizontal overlap with the bounds are skipped. | |
| * | |
| * @param bounds The visible bounds after clipping against the scroll container. | |
| * @param occluders The sticky occluders of that scroll container. | |
| * @param rectCache Optional per-frame cache of element bounding client rects. | |
| * @returns The trimmed bounds, or null if the occluders cover them completely. | |
| */ | |
| function trimByStickyOccluders(bounds: Bounds, occluders: Element[], rectCache?: Map<Element, DOMRect>): Bounds|null { | |
| let {top, bottom} = bounds; | |
| for (const occ of occluders) { | |
| const occRect = getCachedRect(occ, rectCache); | |
| if (occRect.width <= 0 || occRect.height <= 0) { | |
| continue; | |
| } | |
| if (occRect.right <= bounds.left || occRect.left >= bounds.right) { | |
| continue; | |
| } | |
| const coversTop = occRect.top <= top && occRect.bottom > top; | |
| const coversBottom = occRect.top < bottom && occRect.bottom >= bottom; | |
| if (coversTop && coversBottom) { | |
| return null; | |
| } | |
| if (coversTop) { | |
| top = occRect.bottom; | |
| } else if (coversBottom) { | |
| bottom = occRect.top; | |
| } | |
| } | |
| return {left: bounds.left, right: bounds.right, top, bottom}; | |
| } | |
| /** | |
| * Computes the visible viewport-relative bounding box of an element after clipping against | |
| * all ancestor scroll/overflow containers and viewport boundaries across shadow DOM roots. | |
| * | |
| * @param element The source DOM element. | |
| * @param targetRect Optional explicit bounding box (e.g. for sub-lines or custom targets). | |
| * @param rectCache Optional per-frame cache of element bounding client rects to avoid redundant queries. | |
| * @returns The clipped viewport-relative rectangle or null if the element is completely clipped out of view or invisible. | |
| */ | |
| export function computeVisibleRect( | |
| element: Element, | |
| targetRect?: DOMRect, | |
| rectCache?: Map<Element, DOMRect>, | |
| ): VisibleRect|null { | |
| if (!element.isConnected) { | |
| return null; | |
| } | |
| if (!element.checkVisibility({checkOpacity: true, checkVisibilityCSS: true, contentVisibilityAuto: true})) { | |
| return null; | |
| } | |
| const rect = targetRect ?? element.getBoundingClientRect(); | |
| if (rect.width <= 0 || rect.height <= 0) { | |
| return null; | |
| } | |
| let visibleLeft = rect.left; | |
| let visibleRight = rect.right; | |
| let visibleTop = rect.top; | |
| let visibleBottom = rect.bottom; | |
| const doc = element.ownerDocument || document; | |
| const win = doc.defaultView || window; | |
| const viewportWidth = win.innerWidth || doc.documentElement.clientWidth; | |
| const viewportHeight = win.innerHeight || doc.documentElement.clientHeight; | |
| visibleLeft = Math.max(visibleLeft, 0); | |
| visibleTop = Math.max(visibleTop, 0); | |
| visibleRight = Math.min(visibleRight, viewportWidth); | |
| visibleBottom = Math.min(visibleBottom, viewportHeight); | |
| if (visibleLeft >= visibleRight || visibleTop >= visibleBottom) { | |
| return null; | |
| } | |
| const clippingAncestors = getClippingAncestors(element, doc, win); | |
| for (const {element: ancestor, clipsX, clipsY, occluders} of clippingAncestors) { | |
| const parentRect = getCachedRect(ancestor, rectCache); | |
| if (clipsX) { | |
| visibleLeft = Math.max(visibleLeft, parentRect.left); | |
| visibleRight = Math.min(visibleRight, parentRect.right); | |
| } | |
| if (clipsY) { | |
| visibleTop = Math.max(visibleTop, parentRect.top); | |
| visibleBottom = Math.min(visibleBottom, parentRect.bottom); | |
| } | |
| if (visibleLeft >= visibleRight || visibleTop >= visibleBottom) { | |
| return null; | |
| } | |
| const trimmed = trimByStickyOccluders( | |
| {left: visibleLeft, right: visibleRight, top: visibleTop, bottom: visibleBottom}, occluders, rectCache); | |
| if (!trimmed) { | |
| return null; | |
| } | |
| ({top: visibleTop, bottom: visibleBottom} = trimmed); | |
| } | |
| const width = visibleRight - visibleLeft; | |
| const height = visibleBottom - visibleTop; | |
| if (width <= 0 || height <= 0) { | |
| return null; | |
| } | |
| return { | |
| left: visibleLeft, | |
| top: visibleTop, | |
| right: visibleRight, | |
| bottom: visibleBottom, | |
| width, | |
| height, | |
| }; | |
| } | |
| /** | |
| * Checks whether an element is connected to the DOM, visible according to `checkVisibility()`, | |
| * and has non-zero bounding box dimensions. | |
| * | |
| * @param element The element to check visibility for. | |
| * @returns True if the element is connected and rendered with non-zero size; otherwise false. | |
| */ | |
| export function isElementVisible(element: Element): boolean { | |
| if (!element.isConnected) { | |
| return false; | |
| } | |
| if (!element.checkVisibility({checkOpacity: true, checkVisibilityCSS: true, contentVisibilityAuto: true})) { | |
| return false; | |
| } | |
| const rect = element.getBoundingClientRect(); | |
| return rect.width > 0 && rect.height > 0; | |
| } |