Merge changes I23e4f19f,I2eb64e18,Ia8f1ca47,I9090d834 into stable-3.6

* changes:
  Migrate gr-diff-highlight from PolymerElement to plain class
  Simplify and clean up gr-diff-highlight
  Migrate gr-diff-highlight_test from js to ts
  Migrate gr-diff-selection from PolymerElement to plain class
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 9b093af..c64f484 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -548,7 +548,7 @@
         end_line: 6,
         end_character: 1,
       };
-      diffElement.$.highlights.selectedRange = {
+      diffElement.highlights.selectedRange = {
         side: 'right',
         range: someRange,
       };
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 2ee6c9f..0714645 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -1,43 +1,25 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
 import {GrAnnotation} from './gr-annotation';
 import {normalize} from './gr-range-normalizer';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
 import {
   getLineElByChild,
   getLineNumberByChild,
-  getRange,
-  getSide,
   getSideByLineEl,
   GrDiffThreadElement,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -56,51 +38,65 @@
   end: NormalizedPosition | null;
 }
 
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | null;
+}
 
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
   selectedRange?: SidedRange;
 
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
   private selectionChangeTask?: DelayedTask;
 
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
     );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
     );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
     );
   }
 
-  override disconnectedCallback() {
+  cleanup() {
     this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
     }
-    return this._cachedDiffBuilder;
   }
 
   /**
@@ -129,18 +125,17 @@
     // removed.
     // If you wait longer than 50 ms, then you don't properly catch a very
     // quick 'c' press after the selection change. If you wait less than 10
-    // ms, then you will have about 50 _handleSelection calls when doing a
+    // ms, then you will have about 50 handleSelection() calls when doing a
     // simple drag for select.
     this.selectionChangeTask = debounce(
       this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
+      () => this.handleSelection(selection, isMouseUp),
       10
     );
   }
 
-  _getThreadEl(e: Event): GrDiffThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
       if (
         pathEl instanceof HTMLElement &&
         pathEl.classList.contains('comment-thread')
@@ -151,130 +146,74 @@
     return null;
   }
 
-  _toggleRangeElHighlight(
-    threadEl: GrDiffThreadElement,
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
     highlightRange = false
   ) {
-    // We don't want to re-create the line just for highlighting the range which
-    // is creating annoying bugs: @see Issue 12934
-    // As gr-ranged-comment-layer now does not notify the layer re-render and
-    // lack of access to the thread or the lineEl from the ranged-comment-layer,
-    // need to update range class for styles here.
-    let curNode: HTMLElement | null = threadEl.assignedSlot;
-    while (curNode) {
-      if (curNode.nodeName === 'TABLE') break;
-      curNode = curNode.parentElement;
-    }
-    if (curNode?.querySelectorAll) {
-      if (highlightRange) {
-        const rangeNodes = curNode.querySelectorAll(
-          `.range.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.add('rangeHoverHighlight')
-            );
-        }
-      } else {
-        const rangeNodes = curNode.querySelectorAll(
-          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHoverHighlight');
-        });
-        const hintNode = threadEl.parentElement?.querySelector(
-          `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
-        );
-        if (hintNode) {
-          hintNode.shadowRoot
-            ?.querySelectorAll('.rangeHoverHighlight')
-            .forEach(highlightNode =>
-              highlightNode.classList.remove('rangeHoverHighlight')
-            );
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _handleCommentThreadMouseleave(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], false);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
-  }
-
-  _indexForThreadEl(threadEl: HTMLElement) {
-    const side = getSide(threadEl);
-    const range = getRange(threadEl);
-    if (!side || !range) return undefined;
-    return this._indexOfCommentRange(side, range);
-  }
-
-  _indexOfCommentRange(side: Side, range: CommentRange) {
-    function rangesEqual(a: CommentRange, b: CommentRange) {
-      if (!a && !b) {
-        return true;
-      }
-      if (!a || !b) {
-        return false;
-      }
-      return (
-        a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
       );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
     }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
   }
 
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
   /**
    * Get current normalized selection.
    * Merges multiple ranges, accounts for triple click, accounts for
    * syntax highligh, convert native DOM Range objects to Gerrit concepts
    * (line, side, etc).
    */
-  _getNormalizedRange(selection: Selection | Range) {
+  private getNormalizedRange(selection: Selection | Range) {
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
        we can get is a single Range */
     if (selection instanceof Range) {
-      return this._normalizeRange(selection);
+      return this.normalizeRange(selection);
     }
     const rangeCount = selection.rangeCount;
     if (rangeCount === 0) {
       return null;
     } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
+      return this.normalizeRange(selection.getRangeAt(0));
     } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
         selection.getRangeAt(rangeCount - 1)
       );
       return {
@@ -289,15 +228,15 @@
    *
    * @return fixed normalized range
    */
-  _normalizeRange(domRange: Range): NormalizedRange {
+  private normalizeRange(domRange: Range): NormalizedRange {
     const range = normalize(domRange);
-    return this._fixTripleClickSelection(
+    return this.fixTripleClickSelection(
       {
-        start: this._normalizeSelectionSide(
+        start: this.normalizeSelectionSide(
           range.startContainer,
           range.startOffset
         ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
       },
       domRange
     );
@@ -313,7 +252,7 @@
    * @param domRange DOM Range object
    * @return fixed normalized range
    */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
     if (!range.start) {
       // Selection outside of current diff.
       return range;
@@ -334,7 +273,7 @@
       end.column === 0 &&
       end.line === start.line + 1;
     const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
+    const lineLength = (content && this.getLength(content)) || 0;
     if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
       // Move the selection to the end of the previous line.
       range.end = {
@@ -355,12 +294,14 @@
    * @param node td.content child
    * @param offset offset within node
    */
-  _normalizeSelectionSide(
+  private normalizeSelectionSide(
     node: Node | null,
     offset: number
   ): NormalizedPosition | null {
     let column;
-    if (!node || !this.contains(node)) return null;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
     const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
     const side = getSideByLineEl(lineEl);
@@ -376,10 +317,10 @@
     } else {
       const thread = contentTd.querySelector('.comment-thread');
       if (thread?.contains(node)) {
-        column = this._getLength(contentText);
+        column = this.getLength(contentText);
         node = contentText;
       } else {
-        column = this._convertOffsetToColumn(node, offset);
+        column = this.convertOffsetToColumn(node, offset);
       }
     }
 
@@ -398,7 +339,8 @@
    * collapsed section, so don't need to worry about this case for
    * positioning the tooltip.
    */
-  _positionActionBox(
+  // visible for testing
+  positionActionBox(
     actionBox: GrSelectionActionBox,
     startLine: number,
     range: Text | Element | Range
@@ -412,7 +354,7 @@
     actionBox.placeBelow(range);
   }
 
-  _isRangeValid(range: NormalizedRange | null) {
+  private isRangeValid(range: NormalizedRange | null) {
     if (!range || !range.start || !range.start.node || !range.end) {
       return false;
     }
@@ -425,15 +367,16 @@
     );
   }
 
-  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
     /* On Safari, the selection events may return a null range that should
        be ignored */
-    if (!selection) {
-      return;
-    }
-    const normalizedRange = this._getNormalizedRange(selection);
-    if (!this._isRangeValid(normalizedRange)) {
-      this._removeActionBox();
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
       return;
     }
     /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
@@ -463,8 +406,8 @@
       // start.column with the content length), we just check if the selection
       // is empty to see that it's at the end of a line.
       const content = domRange.cloneContents().querySelector('.contentText');
-      if (isMouseUp && this._getLength(content) === 0) {
-        this._fireCreateRangeComment(start.side, {
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -474,10 +417,10 @@
       return;
     }
 
-    let actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+      this.diffTable.appendChild(actionBox);
     }
     this.selectedRange = {
       range: {
@@ -489,10 +432,10 @@
       side: start.side,
     };
     if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
+      this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
       if (start.column) {
-        this._positionActionBox(
+        this.positionActionBox(
           actionBox,
           start.line,
           start.node.splitText(start.column)
@@ -505,44 +448,41 @@
       (start.node.firstChild instanceof Element ||
         start.node.firstChild instanceof Text)
     ) {
-      this._positionActionBox(actionBox, start.line, start.node.firstChild);
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
     } else if (start.node instanceof Element || start.node instanceof Text) {
-      this._positionActionBox(actionBox, start.line, start.node);
+      this.positionActionBox(actionBox, start.line, start.node);
     } else {
       console.warn('Failed to position comment action box.');
-      this._removeActionBox();
+      this.removeActionBox();
     }
   }
 
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
       new CustomEvent('create-range-comment', {
         detail: {side, range},
         composed: true,
         bubbles: true,
       })
     );
-    this._removeActionBox();
+    this.removeActionBox();
   }
 
-  _handleRangeCommentRequest(e: Event) {
+  private handleRangeCommentRequest = (e: Event) => {
     e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
+    assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
+    this.fireCreateRangeComment(side, range);
+  };
 
-  _removeActionBox() {
+  // visible for testing
+  removeActionBox() {
     this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
   }
 
-  _convertOffsetToColumn(el: Node, offset: number) {
+  private convertOffsetToColumn(el: Node, offset: number) {
     if (el instanceof Element && el.classList.contains('content')) {
       return offset;
     }
@@ -552,7 +492,7 @@
     ) {
       if (el.previousSibling) {
         el = el.previousSibling;
-        offset += this._getLength(el);
+        offset += this.getLength(el);
       } else {
         el = el.parentElement!;
       }
@@ -566,18 +506,24 @@
    *
    * @param node this is sometimes passed as null.
    */
-  _getLength(node: Node | null): number {
+  // visible for testing
+  getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
+      return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
   }
 }
 
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
 declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to appear above wrapped content, since it's inserted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate()[1];
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sinon.stub().returns([]),
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sinon.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = document.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentTdByLine: sinon.stub(),
-        getContentTdByLineEl: sinon.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sinon.stub(),
-        getSideByLineEl: sinon.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      document.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sinon.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sinon.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.parentElement, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = document.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = _getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = _getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..b819754
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,713 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-diff-highlight';
+import {_getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html} from '@open-wc/testing-helpers';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {waitQueryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stub('gr-selection-action-box', 'placeAbove');
+      stub('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = _getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = _getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -1,39 +1,23 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
 import {
   normalize,
   NormalizedRange,
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   getLineElByChild,
   getSide,
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -55,49 +39,35 @@
   return {left: null, right: null};
 }
 
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrDiffSelection {
+  // visible for testing
   diff?: DiffInfo;
 
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
+  // visible for testing
+  diffTable?: HTMLElement;
 
-  @property({type: Object})
-  _linesCache: LinesCache = {left: null, right: null};
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
 
-  constructor() {
-    super();
-    this.addEventListener('copy', e => this._handleCopy(e));
-    addListener(this, 'down', e => this._handleDown(e));
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.classList.add(SelectionClass.RIGHT);
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
   }
 
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  @observe('diff')
-  _diffChanged() {
-    this._linesCache = getNewCache();
-  }
-
-  _handleDownOnRangeComment(node: Element) {
+  handleDownOnRangeComment(node: Element) {
     if (isThreadEl(node)) {
-      this._setClasses([
+      this.setClasses([
         SelectionClass.COMMENT,
         getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
@@ -108,14 +78,13 @@
     return false;
   }
 
-  _handleDown(e: Event) {
+  handleDown = (e: Event) => {
     const target = e.target;
     if (!(target instanceof Element)) return;
-    // Handle the down event on comment thread in Polymer 2
-    const handled = this._handleDownOnRangeComment(target);
+    const handled = this.handleDownOnRangeComment(target);
     if (handled) return;
     const lineEl = getLineElByChild(target);
-    const blameSelected = this._elementDescendedFromClass(target, 'blame');
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
     if (!lineEl && !blameSelected) {
       return;
     }
@@ -125,9 +94,10 @@
     if (blameSelected) {
       targetClasses.push(SelectionClass.BLAME);
     } else if (lineEl) {
-      const commentSelected = this._elementDescendedFromClass(
+      const commentSelected = descendedFromClass(
         target,
-        'gr-comment'
+        'gr-comment',
+        this.diffTable
       );
       const side = getSideByLineEl(lineEl);
 
@@ -140,60 +110,50 @@
       }
     }
 
-    this._setClasses(targetClasses);
-  }
+    this.setClasses(targetClasses);
+  };
 
   /**
    * Set the provided list of classes on the element, to the exclusion of all
    * other SelectionClass values.
    */
-  _setClasses(targetClasses: string[]) {
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
     // Remove any selection classes that do not belong.
     for (const className of Object.values(SelectionClass)) {
       if (!targetClasses.includes(className)) {
-        this.classList.remove(className);
+        this.diffTable.classList.remove(className);
       }
     }
     // Add new selection classes iff they are not already present.
-    for (const _class of targetClasses) {
-      if (!this.classList.contains(_class)) {
-        this.classList.add(_class);
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
       }
     }
   }
 
-  _getCopyEventTarget(e: Event) {
-    return (dom(e) as EventApi).rootTarget;
-  }
-
-  /**
-   * Utility function to determine whether an element is a descendant of
-   * another element with the particular className.
-   */
-  _elementDescendedFromClass(element: Element, className: string) {
-    return descendedFromClass(element, className, this.diffBuilder.diffElement);
-  }
-
-  _handleCopy(e: ClipboardEvent) {
+  handleCopy = (e: ClipboardEvent) => {
     let commentSelected = false;
-    const target = this._getCopyEventTarget(e);
+    const target = e.composedPath()[0];
     if (!(target instanceof Element)) return;
     if (target instanceof HTMLTextAreaElement) return;
-    if (!this._elementDescendedFromClass(target, 'diff-row')) return;
-    if (this.classList.contains(SelectionClass.COMMENT)) {
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this._getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
     }
-  }
+  };
 
-  _getSelection() {
+  getSelection() {
     const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return document.getSelection();
 
@@ -219,13 +179,13 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  _getSelectedText(side: Side, commentSelected: boolean) {
-    const sel = this._getSelection();
+  getSelectedText(side: Side, commentSelected: boolean) {
+    const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
     if (commentSelected) {
-      return this._getCommentLines(sel, side);
+      return this.getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
@@ -250,7 +210,7 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this._getRangeFromDiff(
+    return this.getRangeFromDiff(
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -262,7 +222,7 @@
   /**
    * Query the diff object for the selected lines.
    */
-  _getRangeFromDiff(
+  getRangeFromDiff(
     startLineNum: number,
     startOffset: number,
     endLineNum: number | undefined,
@@ -274,7 +234,7 @@
       startLineNum -= skipChunk.skip!;
       if (endLineNum) endLineNum -= skipChunk.skip!;
     }
-    const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
     if (lines.length) {
       lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
       lines[0] = lines[0].substring(startOffset);
@@ -288,9 +248,9 @@
    * @param side The side that is currently selected.
    * @return An array of strings indexed by line number.
    */
-  _getDiffLines(side: Side): string[] {
-    if (this._linesCache[side]) {
-      return this._linesCache[side]!;
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
     }
     if (!this.diff) return [];
     let lines: string[] = [];
@@ -303,7 +263,7 @@
         lines = lines.concat(chunk.b);
       }
     }
-    this._linesCache[side] = lines;
+    this.linesCache[side] = lines;
     return lines;
   }
 
@@ -315,11 +275,11 @@
    * @param side The side that is currently selected.
    * @return The selected comment text.
    */
-  _getCommentLines(sel: Selection, side: Side) {
+  getCommentLines(sel: Selection, side: Side) {
     const range = normalize(sel.getRangeAt(0));
     const content = [];
-    // Query the diffElement for comments.
-    const messages = this.diffBuilder.diffElement.querySelectorAll(
+    assertIsDefined(this.diffTable, 'diffTable');
+    const messages = this.diffTable.querySelectorAll(
       `.side-by-side [data-side="${side}"] .message *, .unified .message *`
     );
 
@@ -339,9 +299,9 @@
 
         if (
           el.id === 'output' &&
-          !this._elementDescendedFromClass(el, 'collapsed')
+          !descendedFromClass(el, 'collapsed', this.diffTable)
         ) {
-          content.push(this._getTextContentForRange(el, sel, range));
+          content.push(this.getTextContentForRange(el, sel, range));
         }
       }
     }
@@ -359,7 +319,7 @@
    * @param range The normalized selection range.
    * @return The text within the selection.
    */
-  _getTextContentForRange(
+  getTextContentForRange(
     domNode: Node,
     sel: Selection,
     range: NormalizedRange
@@ -379,15 +339,9 @@
       }
     } else {
       for (const childNode of domNode.childNodes) {
-        text += this._getTextContentForRange(childNode, sel, range);
+        text += this.getTextContentForRange(childNode, sel, range);
       }
     }
     return text;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-selection': GrDiffSelection;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index dc7f6a2..b44114a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -5,121 +5,115 @@
  */
 import '../../../test/common-test-setup-karma';
 import './gr-diff-selection';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrDiffSelection} from './gr-diff-selection';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {createDiff} from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {Side} from '../../../api/diff';
+import {DiffInfo, Side} from '../../../api/diff';
 import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {mouseDown} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-diff-selection>
-    <table id="diffTable" class="side-by-side">
-      <tr class="diff-row">
-        <td class="blame" data-line-number="1"></td>
-        <td class="lineNum left" data-value="1">1</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ba ba</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is a comment</span
-                >
-              </div>
+const diffTableTemplate = html`
+  <table id="diffTable" class="side-by-side">
+    <tr class="diff-row">
+      <td class="blame" data-line-number="1"></td>
+      <td class="lineNum left" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ba ba</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text">This is a comment</span>
             </div>
           </div>
-        </td>
-        <td class="lineNum right" data-value="1">1</td>
-        <td class="content">
-          <div class="contentText" data-side="right">some other text</div>
-        </td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="2"></td>
-        <td class="lineNum left" data-value="2">2</td>
-        <td class="content">
-          <div class="contentText" data-side="left">zin</div>
-        </td>
-        <td class="lineNum right" data-value="2">2</td>
-        <td class="content">
-          <div class="contentText" data-side="right">more more more</div>
-          <div data-side="right">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is a comment on the right</span
-                >
-              </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="1">1</td>
+      <td class="content">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="2"></td>
+      <td class="lineNum left" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="left">zin</div>
+      </td>
+      <td class="lineNum right" data-value="2">2</td>
+      <td class="content">
+        <div class="contentText" data-side="right">more more more</div>
+        <div data-side="right">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is a comment on the right</span
+              >
             </div>
           </div>
-        </td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="3"></td>
-        <td class="lineNum left" data-value="3">3</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ga ga</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <div class="gr-formatted-text message">
-                <span id="output" class="gr-linked-text"
-                  >This is <a>a</a> different comment 💩 unicode is fun</span
-                >
-              </div>
+        </div>
+      </td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="3"></td>
+      <td class="lineNum left" data-value="3">3</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <div class="gr-formatted-text message">
+              <span id="output" class="gr-linked-text"
+                >This is <a>a</a> different comment 💩 unicode is fun</span
+              >
             </div>
           </div>
-        </td>
-        <td class="lineNum right" data-value="3">3</td>
-      </tr>
-      <tr class="diff-row">
-        <td class="blame" data-line-number="4"></td>
-        <td class="lineNum left" data-value="4">4</td>
-        <td class="content">
-          <div class="contentText" data-side="left">ga ga</div>
-          <div data-side="left">
-            <div class="comment-thread">
-              <textarea data-side="right">test for textarea copying</textarea>
-            </div>
+        </div>
+      </td>
+      <td class="lineNum right" data-value="3">3</td>
+    </tr>
+    <tr class="diff-row">
+      <td class="blame" data-line-number="4"></td>
+      <td class="lineNum left" data-value="4">4</td>
+      <td class="content">
+        <div class="contentText" data-side="left">ga ga</div>
+        <div data-side="left">
+          <div class="comment-thread">
+            <textarea data-side="right">test for textarea copying</textarea>
           </div>
-        </td>
-        <td class="lineNum right" data-value="4">4</td>
-      </tr>
-      <tr class="not-diff-row">
-        <td class="other">
-          <div class="contentText" data-side="right">some other text</div>
-        </td>
-      </tr>
-    </table>
-  </gr-diff-selection>
-`);
+        </div>
+      </td>
+      <td class="lineNum right" data-value="4">4</td>
+    </tr>
+    <tr class="not-diff-row">
+      <td class="other">
+        <div class="contentText" data-side="right">some other text</div>
+      </td>
+    </tr>
+  </table>
+`;
 
 suite('gr-diff-selection', () => {
   let element: GrDiffSelection;
-  let getCopyEventTargetStub: sinon.SinonStub;
+  let diffTable: HTMLTableElement;
 
   const emulateCopyOn = function (target: HTMLElement | null) {
     const fakeEvent = {
       target,
       preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
       clipboardData: {
         setData: sinon.stub(),
       },
     };
-    getCopyEventTargetStub.returns(target);
-    element._handleCopy(fakeEvent as unknown as ClipboardEvent);
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
     return fakeEvent;
   };
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrDiffSelection;
+  setup(async () => {
+    element = new GrDiffSelection();
+    diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
 
-    getCopyEventTargetStub = sinon.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      diffElement: element.querySelector('#diffTable')!,
-    } as GrDiffBuilderElement;
-    element.diff = {
+    const diff: DiffInfo = {
       ...createDiff(),
       content: [
         {
@@ -136,228 +130,231 @@
         },
       ],
     };
+    element.init(diff, diffTable);
   });
 
   test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    const lineNumberEl = element.querySelector('.lineNum.left');
-    assert.isOk(lineNumberEl);
-    MockInteractions.down(lineNumberEl!);
+    element.diffTable!.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
     assert.isTrue(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'adds selected-left'
     );
     assert.isFalse(
-      element.classList.contains('selected-right'),
+      element.diffTable!.classList.contains('selected-right'),
       'removes selected-right'
     );
   });
 
   test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    const lineNumberEl = element.querySelector('.lineNum.right');
-    assert.isOk(lineNumberEl);
-    MockInteractions.down(lineNumberEl!);
+    element.diffTable!.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
     assert.isTrue(
-      element.classList.contains('selected-right'),
+      element.diffTable!.classList.contains('selected-right'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
 
   test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    sinon
-      .stub(element, '_elementDescendedFromClass')
-      .callsFake((_: Element, className: string) => className === 'blame');
-    MockInteractions.down(element);
+    element.diffTable!.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    element.diffTable!.appendChild(blameDiv);
+    mouseDown(blameDiv);
     assert.isTrue(
-      element.classList.contains('selected-blame'),
+      element.diffTable!.classList.contains('selected-blame'),
       'adds selected-right'
     );
     assert.isFalse(
-      element.classList.contains('selected-left'),
+      element.diffTable!.classList.contains('selected-left'),
       'removes selected-left'
     );
   });
 
   test('ignores copy for non-content Element', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
     assert.isFalse(getSelectedTextStub.called);
   });
 
   test('asks for text for left side Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
   });
 
   test('reacts to copy for content Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.isTrue(getSelectedTextStub.called);
   });
 
   test('copy event is prevented for content Elements', () => {
-    const getSelectedTextStub = sinon.stub(element, '_getSelectedText');
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     getSelectedTextStub.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.isTrue(event.preventDefault.called);
   });
 
   test('inserts text into clipboard on copy', () => {
-    sinon.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
     assert.deepEqual(
       ['Text', 'the text'],
       event.clipboardData.setData.lastCall.args
     );
   });
 
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    element.diffTable!.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
 
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
+    element.setClasses(['selected-blame']);
+    assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-left'));
+    assert.isFalse(element.diffTable!.classList.contains('selected-right'));
+    assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
   });
 
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sinon.stub(element.classList, 'add');
-    const removeStub = sinon.stub(element.classList, 'remove').callsFake(() => {
-      assert.isFalse(addStub.called);
-    });
-    element._setClasses(['selected-comment', 'selected-left']);
+  test('setClasses removes before it ads', () => {
+    element.diffTable!.classList.add('selected-right');
+    const addStub = sinon.stub(element.diffTable!.classList, 'add');
+    const removeStub = sinon
+      .stub(element.diffTable!.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
     assert.isTrue(addStub.called);
     assert.isTrue(removeStub.called);
   });
 
   test('copies content correctly', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
 
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
     range.setEnd(
-      element.querySelectorAll('div.contentText')[4]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
       2
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
   });
 
   test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
-      element.querySelector('.gr-formatted-text *')!.firstChild!,
+      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
       3
     );
     range.setEnd(
-      element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
+      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
       7
     );
     selection.addRange(range);
     assert.equal(
       's is a comment\nThis is a differ',
-      element._getSelectedText(Side.LEFT, true)
+      element.getSelectedText(Side.LEFT, true)
     );
   });
 
   test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.add('selected-comment');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
+    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
     range.setStart(nodes[2].childNodes[2], 13);
     range.setEnd(nodes[2].childNodes[2], 23);
     selection.addRange(range);
-    assert.equal('mment 💩 u', element._getSelectedText(Side.LEFT, true));
+    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
   });
 
   test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
     assert.isFalse(selectedTextSpy.called);
   });
 
   test('regression test for 4794', () => {
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
+    element.diffTable!.classList.add('selected-right');
+    element.diffTable!.classList.remove('selected-left');
 
     const selection = document.getSelection();
     if (!selection) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
-      element.querySelectorAll('div.contentText')[1]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
       4
     );
     range.setEnd(
-      element.querySelectorAll('div.contentText')[1]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
       10
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.RIGHT, false), ' other');
+    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
   });
 
   test('copies to end of side (issue 7895)', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
+    element.diffTable!.classList.add('selected-left');
+    element.diffTable!.classList.remove('selected-right');
     const selection = document.getSelection();
     if (selection === null) assert.fail('no selection');
     selection.removeAllRanges();
     const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText')!.firstChild!, 3);
+    range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
     range.setEnd(
-      element.querySelectorAll('div.contentText')[4]!.firstChild!,
+      diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
       2
     );
     selection.addRange(range);
-    assert.equal(element._getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
+    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
   });
 
-  suite('_getTextContentForRange', () => {
+  suite('getTextContentForRange', () => {
     let selection: Selection;
     let range: Range;
     let nodes: NodeListOf<GrFormattedText>;
 
     setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
+      element.diffTable!.classList.add('selected-left');
+      element.diffTable!.classList.add('selected-comment');
+      element.diffTable!.classList.remove('selected-right');
       const s = document.getSelection();
       if (s === null) assert.fail('no selection');
       selection = s;
       selection.removeAllRanges();
       range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
+      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
     });
 
     test('multi level element contained in range', () => {
@@ -365,7 +362,7 @@
       range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'his is a differ'
       );
     });
@@ -375,7 +372,7 @@
       range.setEnd(nodes[2].childNodes[2], 7);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'a differ'
       );
     });
@@ -385,16 +382,9 @@
       range.setEnd(nodes[0].firstChild!, 12);
       selection.addRange(range);
       assert.equal(
-        element._getTextContentForRange(element, selection, range),
+        element.getTextContentForRange(diffTable, selection, range),
         'is is a co'
       );
     });
   });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: ['test'], right: ['test']};
-    element.diff = createDiff();
-    flush();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index a38ec91..34c2a33 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -80,6 +80,7 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -99,7 +100,6 @@
 
 export interface GrDiff {
   $: {
-    highlights: GrDiffHighlight;
     diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
@@ -294,6 +294,10 @@
 
   private renderDiffTableTask?: DelayedTask;
 
+  private diffSelection = new GrDiffSelection();
+
+  private highlights = new GrDiffHighlight();
+
   constructor() {
     super();
     this._setLoading(true);
@@ -315,6 +319,8 @@
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
     super.disconnectedCallback();
   }
 
@@ -357,7 +363,7 @@
     // and pass the shadow DOM selection into gr-diff-highlight, where the
     // corresponding range is determined and normalized.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, false);
+    this.highlights.handleSelectionChange(selection, false);
   };
 
   private readonly handleMouseUp = () => {
@@ -365,7 +371,7 @@
     // mouse-up if there's a selection that just covers a line change. We
     // can't do that on selection change since the user may still be dragging.
     const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, true);
+    this.highlights.handleSelectionChange(selection, true);
   };
 
   /** Gets the current selection, preferring the shadow DOM selection. */
@@ -404,7 +410,7 @@
       const range = getRange(threadEl);
       if (!range) return undefined;
 
-      return {side, range, hovering: false, rootId: threadEl.rootId};
+      return {side, range, rootId: threadEl.rootId};
     }
 
     // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
@@ -430,7 +436,6 @@
       this.push('_commentRanges', {
         side: Side.RIGHT,
         range: this.highlightRange,
-        hovering: true,
         rootId: '',
       });
     }
@@ -498,7 +503,7 @@
   }
 
   isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
+    return !!this.highlights.selectedRange;
   }
 
   toggleLeftDiff() {
@@ -590,7 +595,7 @@
     if (!this.isRangeSelected()) {
       throw Error('Selection is needed for new range comment');
     }
-    const selectedRange = this.$.highlights.selectedRange;
+    const selectedRange = this.highlights.selectedRange;
     if (!selectedRange) throw Error('selected range not set');
     const {side, range} = selectedRange;
     this._createCommentForSelection(side, range);
@@ -813,6 +818,10 @@
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
+    if (this.diff) {
+      this.diffSelection.init(this.diff, this.$.diffTable);
+      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index e05e85a..6d36b89 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -487,6 +487,10 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable {
+      /* for gr-selection-action-box positioning */
+      position: relative;
+    }
     #diffTable:focus {
       outline: none;
     }
@@ -670,6 +674,14 @@
     .token-highlight {
       background-color: var(--token-highlighting-color, #fffd54);
     }
+
+    gr-selection-action-box {
+      /**
+       * Needs z-index to appear above wrapped content, since it's inserted
+       * into DOM before it.
+       */
+      z-index: 10;
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -686,44 +698,36 @@
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
   >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-          use-new-image-diff-ui="[[useNewImageDiffUi]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-            contenteditable$="[[isContentEditable]]"
-          ></table>
+    <gr-diff-builder
+      id="diffBuilder"
+      comment-ranges="[[_commentRanges]]"
+      coverage-ranges="[[coverageRanges]]"
+      diff="[[diff]]"
+      path="[[path]]"
+      view-mode="[[viewMode]]"
+      is-image-diff="[[isImageDiff]]"
+      base-image="[[baseImage]]"
+      layers="[[layers]]"
+      revision-image="[[revisionImage]]"
+      use-new-image-diff-ui="[[useNewImageDiffUi]]"
+    >
+      <table
+        id="diffTable"
+        class$="[[_diffTableClass]]"
+        role="presentation"
+        contenteditable$="[[isContentEditable]]"
+      ></table>
 
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
+      <template
+        is="dom-if"
+        if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
+      >
+        <div class="whitespace-change-only-message">
+          This file only contains whitespace changes. Modify the whitespace
+          setting to see the changes.
+        </div>
+      </template>
+    </gr-diff-builder>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index ba4fa9f..c8d8a2f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -50,21 +50,21 @@
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
+      sinon.stub(element.highlights, 'handleSelectionChange');
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+      assert.isTrue(element.highlights.handleSelectionChange.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+      assert.isFalse(element.highlights.handleSelectionChange.called);
     });
   });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 6c8a5e9..70cec64 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -20,7 +20,6 @@
 export interface CommentRangeLayer {
   side: Side;
   range: CommentRange;
-  hovering: boolean;
   // New drafts don't have a rootId.
   rootId?: string;
 }
@@ -40,7 +39,6 @@
  * highlights.
  */
 interface CommentRangeLineLayer {
-  hovering: boolean;
   longRange: boolean;
   id: string;
   // start char (0-based)
@@ -59,7 +57,7 @@
 
 const RANGE_BASE_ONLY = 'style-scope gr-diff range';
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
 export class GrRangedCommentLayer implements DiffLayer {
   private knownRanges: CommentRangeLayer[] = [];
@@ -95,11 +93,8 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.id)}`
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
       );
     }
   }
@@ -139,17 +134,15 @@
   }
 
   private addRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     const longRange = isLongCommentRange(range);
     this.updateRangesMap({
       side,
       range,
-      hovering,
-      operation: (forLine, startChar, endChar, hovering) => {
+      operation: (forLine, startChar, endChar) => {
         forLine.push({
           start: startChar,
           end: endChar,
-          hovering,
           id: id(commentRange),
           longRange,
         });
@@ -158,11 +151,10 @@
   }
 
   private removeRange(commentRange: CommentRangeLayer) {
-    const {side, range, hovering} = commentRange;
+    const {side, range} = commentRange;
     this.updateRangesMap({
       side,
       range,
-      hovering,
       operation: forLine => {
         const index = forLine.findIndex(
           lineRange => id(commentRange) === lineRange.id
@@ -175,21 +167,19 @@
   private updateRangesMap(options: {
     side: Side;
     range: CommentRange;
-    hovering: boolean;
     operation: (
       forLine: CommentRangeLineLayer[],
       start: number,
-      end: number,
-      hovering: boolean
+      end: number
     ) => void;
   }) {
-    const {side, range, hovering, operation} = options;
+    const {side, range, operation} = options;
     const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
       const start = line === range.start_line ? range.start_character : 0;
       const end = line === range.end_line ? range.end_character : -1;
-      operation(forLine, start, end, hovering);
+      operation(forLine, start, end);
     }
     this.notifyUpdateRange(range.start_line, range.end_line, side);
   }
@@ -199,25 +189,20 @@
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
     if (lineNum === 'FILE' || lineNum === 'LOST') return [];
     const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
-    return (
-      ranges
-        .map(range => {
-          // Make a copy, so that the normalization below does not mess with
-          // our map.
-          range = {...range};
-          range.end = range.end === -1 ? line.text.length : range.end;
+    return ranges.map(range => {
+      // Make a copy, so that the normalization below does not mess with
+      // our map.
+      range = {...range};
+      range.end = range.end === -1 ? line.text.length : range.end;
 
-          // Normalize invalid ranges where the start is after the end but the
-          // start still makes sense. Set the end to the end of the line.
-          // @see Issue 5744
-          if (range.start >= range.end && range.start < line.text.length) {
-            range.end = line.text.length;
-          }
+      // Normalize invalid ranges where the start is after the end but the
+      // start still makes sense. Set the end to the end of the line.
+      // @see Issue 5744
+      if (range.start >= range.end && range.start < line.text.length) {
+        range.end = line.text.length;
+      }
 
-          return range;
-        })
-        // Sort the ranges so that hovering highlights are on top.
-        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
-    );
+      return range;
+    });
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 4e35645..15d14e3 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -24,7 +24,6 @@
     start_line: 36,
   },
   rootId: 'a',
-  hovering: false,
 };
 
 const rangeB: CommentRangeLayer = {
@@ -36,7 +35,6 @@
     start_line: 10,
   },
   rootId: 'b',
-  hovering: false,
 };
 
 const rangeC: CommentRangeLayer = {
@@ -47,7 +45,6 @@
     start_character: 5,
     start_line: 100,
   },
-  hovering: false,
 };
 
 const rangeD: CommentRangeLayer = {
@@ -59,7 +56,6 @@
     start_line: 55,
   },
   rootId: 'd',
-  hovering: false,
 };
 
 const rangeE: CommentRangeLayer = {
@@ -70,7 +66,6 @@
     start_character: 1,
     start_line: 60,
   },
-  hovering: false,
 };
 
 suite('gr-ranged-comment-layer', () => {
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cc292e6..2fffc9a 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -33,6 +33,7 @@
   CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
@@ -652,6 +653,15 @@
   };
 }
 
+export function createRange(): CommentRange {
+  return {
+    start_line: 1,
+    start_character: 0,
+    end_line: 1,
+    end_character: 1,
+  };
+}
+
 export function createComment(
   extra: Partial<CommentInfo | DraftInfo> = {}
 ): CommentInfo {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 0c63de0..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -296,6 +296,19 @@
   element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
 }
 
+export function mouseDown(element: HTMLElement) {
+  const rect = element.getBoundingClientRect();
+  const eventOptions = {
+    bubbles: true,
+    composed: true,
+    clientX: (rect.left + rect.right) / 2,
+    clientY: (rect.top + rect.bottom) / 2,
+    screenX: (rect.left + rect.right) / 2,
+    screenY: (rect.top + rect.bottom) / 2,
+  };
+  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
 export function assertFails(promise: Promise<unknown>, error?: unknown) {
   promise
     .then((_v: unknown) => {