| /** | 
 |  * @license | 
 |  * Copyright 2021 Google LLC | 
 |  * SPDX-License-Identifier: Apache-2.0 | 
 |  */ | 
 | import {DiffLayer} from '../../../types/types'; | 
 | import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff'; | 
 | import {assertIsDefined} from '../../../utils/common-util'; | 
 | import { | 
 |   GrAnnotationImpl, | 
 |   getStringLength, | 
 | } from '../gr-diff-highlight/gr-annotation'; | 
 | import {debounce, DelayedTask} from '../../../utils/async-util'; | 
 | import {getLineElByChild, getSideByLineEl} from '../gr-diff/gr-diff-utils'; | 
 | import { | 
 |   getLineNumberByChild, | 
 |   lineNumberToNumber, | 
 | } from '../gr-diff/gr-diff-utils'; | 
 |  | 
 | const tokenMatcher = new RegExp(/[\w]+/g); | 
 |  | 
 | /** CSS class for all tokens. */ | 
 | const CSS_TOKEN = 'token'; | 
 |  | 
 | /** CSS class for the currently hovered token. */ | 
 | const CSS_HIGHLIGHT = 'token-highlight'; | 
 |  | 
 | /** CSS class marking which text value each token corresponds */ | 
 | const TOKEN_TEXT_PREFIX = 'tk-text-'; | 
 |  | 
 | /** | 
 |  * CSS class marking which index (column) where token starts within a line of code. | 
 |  * The assumption is that we can only have a single token per column start per line. | 
 |  */ | 
 | const TOKEN_INDEX_PREFIX = 'tk-index-'; | 
 |  | 
 | export const HOVER_DELAY_MS = 200; | 
 |  | 
 | const LINE_LENGTH_LIMIT = 500; | 
 |  | 
 | const TOKEN_LENGTH_LIMIT = 100; | 
 |  | 
 | const TOKEN_COUNT_LIMIT = 10000; | 
 |  | 
 | const TOKEN_OCCURRENCES_LIMIT = 1000; | 
 |  | 
 | /** | 
 |  * When a user hovers over a token in the diff, then this layer makes sure that | 
 |  * all occurrences of this token are annotated with the 'token-highlight' css | 
 |  * class. And removes that class when the user moves the mouse away from the | 
 |  * token. | 
 |  * | 
 |  * The layer does not react to mouse events directly by adding a css class to | 
 |  * the appropriate elements, but instead it just sets the currently highlighted | 
 |  * token and notifies the diff renderer that certain lines must be re-rendered. | 
 |  * And when that re-rendering happens the appropriate css class is added. | 
 |  */ | 
 | export class TokenHighlightLayer implements DiffLayer { | 
 |   /** The currently highlighted token. */ | 
 |   private currentHighlight?: string; | 
 |  | 
 |   /** Trigger when a new token starts or stoped being highlighted.*/ | 
 |   private readonly tokenHighlightListener?: TokenHighlightListener; | 
 |  | 
 |   /** | 
 |    * The line of the currently highlighted token. We store this in order to | 
 |    * re-render only relevant lines of the diff. Only lines visible on the screen | 
 |    * need a highlight. For example in a file with 10,000 lines it is sufficient | 
 |    * to just re-render the ~100 lines that are visible to the user. | 
 |    * | 
 |    * It is a known issue that we are only storing the line number on the side of | 
 |    * where the user is hovering and we use that also to determine which line | 
 |    * numbers to re-render on the other side, but it is non-trivial to look up or | 
 |    * store a reliable mapping of line numbers, so we just accept this | 
 |    * shortcoming with the reasoning that the user is mostly interested in the | 
 |    * tokens on the side where they are hovering anyway. | 
 |    * | 
 |    * Another known issue is that we are not able to see past collapsed lines | 
 |    * with the current implementation. | 
 |    */ | 
 |   private currentHighlightLineNumber = 0; | 
 |  | 
 |   /** | 
 |    * Keeps track of where tokens occur in a file during rendering, so that it is | 
 |    * easy to look up when processing mouse events. | 
 |    */ | 
 |   private tokenToElements = new Map<string, Set<HTMLElement>>(); | 
 |  | 
 |   private hoveredElement?: Element; | 
 |  | 
 |   private updateTokenTask?: DelayedTask; | 
 |  | 
 |   /** | 
 |    * Container that contains all annotated tokens and contains no shadow root | 
 |    * elements that would prevent tokens to be queryable by querySelectorAll. | 
 |    */ | 
 |   private getTokenQueryContainer?: () => HTMLElement; | 
 |  | 
 |   /** | 
 |    * @param container for registering "deselect" click | 
 |    * @param tokenHighlightListener method that is called, | 
 |    *   when token is highlighted. | 
 |    * @param getTokenQueryContainer if specified, list of tokens to be | 
 |    *   highlighted are recalculated every time using querySelectorAll inside | 
 |    *   this element. Otherwise, the pointers calculated once at annotate() time | 
 |    *   and are reused. | 
 |    */ | 
 |   constructor( | 
 |     container: HTMLElement, | 
 |     tokenHighlightListener?: TokenHighlightListener, | 
 |     getTokenQueryContainer?: () => HTMLElement | 
 |   ) { | 
 |     this.tokenHighlightListener = tokenHighlightListener; | 
 |     container.addEventListener('click', e => { | 
 |       this.handleContainerClick(e); | 
 |     }); | 
 |     this.getTokenQueryContainer = getTokenQueryContainer; | 
 |   } | 
 |  | 
 |   annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void { | 
 |     const text = el.textContent; | 
 |     if (!text) return; | 
 |     // Binary files encoded as text for example can have super long lines | 
 |     // with super long tokens. Let's guard against against this scenario. | 
 |     if (text.length > LINE_LENGTH_LIMIT) return; | 
 |     let match; | 
 |     let atLeastOneTokenMatched = false; | 
 |     while ((match = tokenMatcher.exec(text))) { | 
 |       const token = match[0]; | 
 |  | 
 |       // Binary files encoded as text for example can have super long lines | 
 |       // with super long tokens. Let's guard against this scenario. | 
 |       if (token.length > TOKEN_LENGTH_LIMIT) continue; | 
 |  | 
 |       // This is to correctly count surrogate pairs in text and token. | 
 |       // If the index calculation becomes a hotspot, we could precompute a code | 
 |       // unit to code point index map for text before iterating over the results | 
 |       const index = getStringLength(text.slice(0, match.index)); | 
 |       const length = getStringLength(token); | 
 |  | 
 |       atLeastOneTokenMatched = true; | 
 |       const highlightTypeClass = | 
 |         token === this.currentHighlight ? CSS_HIGHLIGHT : ''; | 
 |       const textClass = `${TOKEN_TEXT_PREFIX}${token}`; | 
 |       const indexClass = `${TOKEN_INDEX_PREFIX}${index}`; | 
 |       // We add the TOKEN_TEXT_PREFIX class so that we can look up the token later easily | 
 |       // even if the token element was split up into multiple smaller nodes. | 
 |       // All parts of a single token will share a common TOKEN_INDEX_PREFIX class within the line of code. | 
 |       GrAnnotationImpl.annotateElement( | 
 |         el, | 
 |         index, | 
 |         length, | 
 |         `${textClass} ${indexClass} ${CSS_TOKEN} ${highlightTypeClass}` | 
 |       ); | 
 |       // We could try to detect whether we are re-rendering instead of initially | 
 |       // rendering the line. Then we would not have to call storeLineForToken() | 
 |       // again. But since the Set swallows the duplicates we don't care. | 
 |       this.storeElementsForToken(token, el, textClass); | 
 |     } | 
 |     if (atLeastOneTokenMatched) { | 
 |       // These listeners do not have to be cleaned, because listeners are | 
 |       // garbage collected along with the element itself once it is not attached | 
 |       // to the DOM anymore and no references exist anymore. | 
 |       el.addEventListener('mouseover', e => { | 
 |         this.handleTokenMouseOver(e); | 
 |       }); | 
 |       el.addEventListener('mouseout', e => { | 
 |         this.handleTokenMouseOut(e); | 
 |       }); | 
 |     } | 
 |   } | 
 |  | 
 |   private storeElementsForToken( | 
 |     token: string, | 
 |     lineEl: HTMLElement, | 
 |     cssClass: string | 
 |   ) { | 
 |     for (const el of lineEl.querySelectorAll(`.${cssClass}`)) { | 
 |       let tokenEls = this.tokenToElements.get(token); | 
 |       if (!tokenEls) { | 
 |         // Just to make sure that we don't break down on large files. | 
 |         if (this.tokenToElements.size > TOKEN_COUNT_LIMIT) return; | 
 |         tokenEls = new Set<HTMLElement>(); | 
 |         this.tokenToElements.set(token, tokenEls); | 
 |       } | 
 |       // Just to make sure that we don't break down on large files. | 
 |       if (tokenEls.size > TOKEN_OCCURRENCES_LIMIT) return; | 
 |       tokenEls.add(el as HTMLElement); | 
 |     } | 
 |   } | 
 |  | 
 |   private handleTokenMouseOut(e: MouseEvent) { | 
 |     // If there's no ongoing hover-task, terminate early. | 
 |     if (!this.updateTokenTask?.isActive()) return; | 
 |     if (e.buttons > 0 || this.interferesWithSelection()) return; | 
 |     const {element} = this.findTokenAncestor(e?.target); | 
 |     if (!element) return; | 
 |     if (element === this.hoveredElement) { | 
 |       // If we are moving out of the currently hovered element, cancel the | 
 |       // update task. | 
 |       this.hoveredElement = undefined; | 
 |       if (this.updateTokenTask) this.updateTokenTask.cancel(); | 
 |     } | 
 |   } | 
 |  | 
 |   private handleTokenMouseOver(e: MouseEvent) { | 
 |     if (e.buttons > 0 || this.interferesWithSelection()) return; | 
 |     const { | 
 |       line, | 
 |       token: newHighlight, | 
 |       element, | 
 |     } = this.findTokenAncestor(e?.target); | 
 |     if ( | 
 |       !newHighlight || | 
 |       (this.currentHighlight === newHighlight && | 
 |         this.currentHighlightLineNumber === line) | 
 |     ) | 
 |       return; | 
 |     this.hoveredElement = element; | 
 |     this.updateTokenTask = debounce( | 
 |       this.updateTokenTask, | 
 |       () => { | 
 |         this.updateTokenHighlight(newHighlight, line, element); | 
 |       }, | 
 |       HOVER_DELAY_MS | 
 |     ); | 
 |   } | 
 |  | 
 |   private handleContainerClick(e: MouseEvent) { | 
 |     if (this.interferesWithSelection()) return; | 
 |     // Ignore the click if the click is on a token. | 
 |     // We can't use e.target becauses it gets retargetted to the container as | 
 |     // it's a shadow dom. | 
 |     const {element} = this.findTokenAncestor(e.composedPath()[0]); | 
 |     if (element) return; | 
 |     this.hoveredElement = undefined; | 
 |     this.updateTokenTask?.cancel(); | 
 |     this.updateTokenHighlight(undefined, 0, undefined); | 
 |   } | 
 |  | 
 |   private interferesWithSelection() { | 
 |     return document.getSelection()?.type === 'Range'; | 
 |   } | 
 |  | 
 |   findTokenAncestor(el?: EventTarget | Element | null): { | 
 |     token?: string; | 
 |     line: number; | 
 |     element?: Element; | 
 |   } { | 
 |     if (!(el instanceof Element)) | 
 |       return {line: 0, token: undefined, element: undefined}; | 
 |     if (el.classList.contains(CSS_TOKEN)) { | 
 |       const tkTextClass = [...el.classList].find(c => | 
 |         c.startsWith(TOKEN_TEXT_PREFIX) | 
 |       ); | 
 |       const line = lineNumberToNumber(getLineNumberByChild(el)); | 
 |       if (!line || !tkTextClass) | 
 |         return {line: 0, token: undefined, element: undefined}; | 
 |       return { | 
 |         line, | 
 |         token: tkTextClass.substring(TOKEN_TEXT_PREFIX.length), | 
 |         element: el, | 
 |       }; | 
 |     } | 
 |     if (el.tagName === 'TD') | 
 |       return {line: 0, token: undefined, element: undefined}; | 
 |     return this.findTokenAncestor(el.parentElement); | 
 |   } | 
 |  | 
 |   private updateTokenHighlight( | 
 |     newHighlight: string | undefined, | 
 |     newLineNumber: number, | 
 |     newHoveredElement: Element | undefined | 
 |   ) { | 
 |     if ( | 
 |       this.currentHighlight === newHighlight && | 
 |       this.currentHighlightLineNumber === newLineNumber | 
 |     ) | 
 |       return; | 
 |  | 
 |     const oldHighlight = this.currentHighlight; | 
 |     this.currentHighlight = newHighlight; | 
 |     this.currentHighlightLineNumber = newLineNumber; | 
 |     this.triggerTokenHighlightEvent( | 
 |       newHighlight, | 
 |       newLineNumber, | 
 |       newHoveredElement | 
 |     ); | 
 |     this.toggleTokenHighlight(oldHighlight, CSS_HIGHLIGHT); | 
 |     this.toggleTokenHighlight(newHighlight, CSS_HIGHLIGHT); | 
 |   } | 
 |  | 
 |   private toggleTokenHighlight(token: string | undefined, cssClass: string) { | 
 |     if (!token) { | 
 |       return; | 
 |     } | 
 |     let tokenEls; | 
 |     let tokenElsLength; | 
 |     if (this.getTokenQueryContainer) { | 
 |       tokenEls = this.getTokenQueryContainer().querySelectorAll( | 
 |         `.${TOKEN_TEXT_PREFIX}${token}` | 
 |       ); | 
 |       tokenElsLength = tokenEls.length; | 
 |     } else { | 
 |       tokenEls = this.tokenToElements.get(token); | 
 |       tokenElsLength = tokenEls?.size; | 
 |     } | 
 |     if (!tokenEls || tokenElsLength === 0) { | 
 |       console.warn(`No tokens have been found for '${token}'`); | 
 |       return; | 
 |     } | 
 |     for (const el of tokenEls) { | 
 |       el.classList.toggle(cssClass); | 
 |     } | 
 |   } | 
 |  | 
 |   triggerTokenHighlightEvent( | 
 |     token: string | undefined, | 
 |     line: number, | 
 |     element: Element | undefined | 
 |   ) { | 
 |     if (!this.tokenHighlightListener) { | 
 |       return; | 
 |     } | 
 |     if (!token || !element) { | 
 |       this.tokenHighlightListener(undefined); | 
 |       return; | 
 |     } | 
 |     const lineEl = getLineElByChild(element); | 
 |     assertIsDefined(lineEl, 'Line element should be found!'); | 
 |     const tokenIndexStr = [...element.classList] | 
 |       .find(c => c.startsWith(TOKEN_INDEX_PREFIX)) | 
 |       ?.substring(TOKEN_INDEX_PREFIX.length); | 
 |     assertIsDefined(tokenIndexStr, 'Index class should be found!'); | 
 |     const index = Number(tokenIndexStr); | 
 |     const side = getSideByLineEl(lineEl); | 
 |     const range = { | 
 |       start_line: line, | 
 |       start_column: index + 1, // 1-based inclusive | 
 |       end_line: line, | 
 |       end_column: index + getStringLength(token), // 1-based inclusive | 
 |     }; | 
 |     this.tokenHighlightListener({token, element, side, range}); | 
 |   } | 
 | } |