Update TokenHighlightLayer using class toggle.

Existing implementation works by looking up all the lines that contain
either previously highlighted or newly highlighted tokens and requests
gr-diff to fully re-render those lines.
Which:
- Causes all layers (not just highligh) to re-annotate
- Potentially loses selection/focus
- Slower than proposed solution.

Proposed solution instead stores and looks up all annotated elements
corresponding to highlighted token and toggles the 'token-highlight'
class.

Using a dedicated <style> tag and editing it was considered as
alternative, but it proved to have worse performance.

We are also getting rid of TOKEN_HIGHLIGHT_LIMIT (100). With
TOKEN_OCCURENCE_LIMIT (1000) still in place and the new approach the
benefit from sorting and only highlighting 100 closest tokens doesn't
seem worth added complexity.

Release-Notes: skip
Google-Bug-Id: b/236688880
Change-Id: Ieddd4b186638d3afdbb45e0b9385becc5ef91d33
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index b96b156..19e0e22 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -3,7 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {DiffLayer} from '../../../types/types';
 import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
@@ -44,12 +44,6 @@
 const TOKEN_OCCURRENCES_LIMIT = 1000;
 
 /**
- * Token highlighting is only useful for code on-screen, so we only highlight
- * the nearest set of tokens up to this limit.
- */
-const TOKEN_HIGHLIGHT_LIMIT = 100;
-
-/**
  * 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
@@ -61,9 +55,6 @@
  * And when that re-rendering happens the appropriate css class is added.
  */
 export class TokenHighlightLayer implements DiffLayer {
-  /** The only listener is typically the renderer of gr-diff. */
-  private listeners: DiffLayerListener[] = [];
-
   /** The currently highlighted token. */
   private currentHighlight?: string;
 
@@ -92,9 +83,7 @@
    * Keeps track of where tokens occur in a file during rendering, so that it is
    * easy to look up when processing mouse events.
    */
-  private tokenToLinesLeft = new Map<string, Set<number>>();
-
-  private tokenToLinesRight = new Map<string, Set<number>>();
+  private tokenToElements = new Map<string, Set<HTMLElement>>();
 
   private hoveredElement?: Element;
 
@@ -110,12 +99,7 @@
     });
   }
 
-  annotate(
-    el: HTMLElement,
-    _: HTMLElement,
-    line: GrDiffLine,
-    side: Side
-  ): void {
+  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
@@ -132,7 +116,7 @@
       if (length > TOKEN_LENGTH_LIMIT) continue;
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
-        token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
+        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
@@ -142,12 +126,12 @@
         el,
         index,
         length,
-        `${textClass} ${indexClass} ${highlightTypeClass}`
+        `${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.storeLineForToken(token, line, side);
+      this.storeElementsForToken(token, el, textClass);
     }
     if (atLeastOneTokenMatched) {
       // These listeners do not have to be cleaned, because listeners are
@@ -162,21 +146,23 @@
     }
   }
 
-  private storeLineForToken(token: string, line: GrDiffLine, side: Side) {
-    const tokenToLines =
-      side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
-    // Just to make sure that we don't break down on large files.
-    if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
-    let numbers = tokenToLines.get(token);
-    if (!numbers) {
-      numbers = new Set<number>();
-      tokenToLines.set(token, numbers);
+  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);
     }
-    // Just to make sure that we don't break down on large files.
-    if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
-    const lineNumber =
-      side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    numbers.add(Number(lineNumber));
   }
 
   private handleTokenMouseOut(e: MouseEvent) {
@@ -234,10 +220,7 @@
   } {
     if (!(el instanceof Element))
       return {line: 0, token: undefined, element: undefined};
-    if (
-      el.classList.contains(CSS_TOKEN) ||
-      el.classList.contains(CSS_HIGHLIGHT)
-    ) {
+    if (el.classList.contains(CSS_TOKEN)) {
       const tkTextClass = [...el.classList].find(c =>
         c.startsWith(TOKEN_TEXT_PREFIX)
       );
@@ -265,8 +248,8 @@
       this.currentHighlightLineNumber === newLineNumber
     )
       return;
+
     const oldHighlight = this.currentHighlight;
-    const oldLineNumber = this.currentHighlightLineNumber;
     this.currentHighlight = newHighlight;
     this.currentHighlightLineNumber = newLineNumber;
     this.triggerTokenHighlightEvent(
@@ -274,8 +257,21 @@
       newLineNumber,
       newHoveredElement
     );
-    this.notifyForToken(oldHighlight, oldLineNumber);
-    this.notifyForToken(newHighlight, newLineNumber);
+    this.toggleTokenHighlight(oldHighlight, CSS_HIGHLIGHT);
+    this.toggleTokenHighlight(newHighlight, CSS_HIGHLIGHT);
+  }
+
+  private toggleTokenHighlight(token: string | undefined, cssClass: string) {
+    if (!token) {
+      return;
+    }
+    const tokenEls = this.tokenToElements.get(token);
+    if (!tokenEls) {
+      return;
+    }
+    for (const el of tokenEls) {
+      el.classList.toggle(cssClass);
+    }
   }
 
   triggerTokenHighlightEvent(
@@ -306,58 +302,4 @@
     };
     this.tokenHighlightListener({token, element, side, range});
   }
-
-  getSortedLinesForSide(
-    lineMapping: Map<string, Set<number>>,
-    token: string | undefined,
-    lineNumber: number
-  ): Array<number> {
-    if (!token) return [];
-    const lineSet = lineMapping.get(token);
-    if (!lineSet) return [];
-    const lines = [...lineSet];
-    lines.sort((a, b) => {
-      const da = Math.abs(a - lineNumber);
-      const db = Math.abs(b - lineNumber);
-      // For equal distance, prefer lines later in the file over earlier in the
-      // file. This ensures total ordering.
-      if (da === db) return b - a;
-      // Compare the distance to lineNumber.
-      return da - db;
-    });
-    return lines.slice(0, TOKEN_HIGHLIGHT_LIMIT);
-  }
-
-  notifyForToken(token: string | undefined, lineNumber: number) {
-    const leftLines = this.getSortedLinesForSide(
-      this.tokenToLinesLeft,
-      token,
-      lineNumber
-    );
-    for (const line of leftLines) {
-      this.notifyListeners(line, Side.LEFT);
-    }
-    const rightLines = this.getSortedLinesForSide(
-      this.tokenToLinesRight,
-      token,
-      lineNumber
-    );
-    for (const line of rightLines) {
-      this.notifyListeners(line, Side.RIGHT);
-    }
-  }
-
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  notifyListeners(line: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(line, line, side);
-    }
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index ef2e3d9..c0812b7 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -55,6 +55,7 @@
     highlightDetails?: TokenHighlightEventDetails
   ) {
     tokenHighlightingCalls.push({details: highlightDetails});
+    listener.notify({details: highlightDetails});
   }
 
   setup(async () => {
@@ -63,7 +64,6 @@
     container = document.createElement('div');
     document.body.appendChild(container);
     highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
-    highlighter.addListener((...args) => listener.notify(...args));
   });
 
   teardown(() => {
@@ -126,21 +126,21 @@
         el,
         0,
         5,
-        'tk-text-these tk-index-0 token'
+        'tk-text-these tk-index-0 token '
       );
       assertAnnotation(
         annotateElementStub.args[1],
         el,
         6,
         3,
-        'tk-text-are tk-index-6 token'
+        'tk-text-are tk-index-6 token '
       );
       assertAnnotation(
         annotateElementStub.args[2],
         el,
         10,
         5,
-        'tk-text-words tk-index-10 token'
+        'tk-text-words tk-index-10 token '
       );
     });
 
@@ -196,10 +196,8 @@
 
       // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
       clock.tick(HOVER_DELAY_MS - 100);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
     });
 
     test('highlighting spans many lines', async () => {
@@ -216,10 +214,14 @@
 
       // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [1000, 1000, Side.RIGHT]);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
     });
 
     test('highlighting mouse out before delay', async () => {
@@ -259,10 +261,12 @@
         element: words1,
         range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
       });
+      assert.isTrue(words1.classList.contains('token-highlight'));
 
       container.click();
       assert.equal(tokenHighlightingCalls.length, 2);
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+      assert.isFalse(words1.classList.contains('token-highlight'));
     });
 
     test('triggers listener on token with single occurrence', async () => {
@@ -303,13 +307,13 @@
       dispatchMouseEvent('mouseover', words1);
       assert.equal(listener.pending, 0);
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       listener.flush();
       assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
       container.click();
-      assert.equal(listener.pending, 2);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
+      assert.equal(listener.pending, 1);
+      assert.isFalse(words1.classList.contains('token-highlight'));
     });
 
     test('clicking on word does not clear highlight', async () => {
@@ -323,11 +327,13 @@
       dispatchMouseEvent('mouseover', words1);
       assert.equal(listener.pending, 0);
       clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
+      assert.equal(listener.pending, 1);
       listener.flush();
       assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
       words1.click();
       assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
     });
   });
 });