The only code that is interested in hover events from gr-comment-diff is in gr-diff-highlight, which is used only by gr-diff. For these reasons, it makes more sense for gr-diff (or gr-diff-highlight) to subscribe to the native hover events of the comment threads associated with it, and not require gr-comment to expose any custom events for that. This also has the benefit that it reduces the requirements on thread elements: They can just fire regular mouseenter and mouseleave events like any DOMElement would. This makes it easier to use gr-diff with different comment thread element implementations. I used this change to also make the unit tests for this more meaningful and not stub out all the interesting parts. Change-Id: I1a969bb6a7092bc433039662cd2034dd7141e4ca
		
			
				
	
	
		
			405 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			405 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/**
 | 
						|
 * @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.
 | 
						|
 */
 | 
						|
(function() {
 | 
						|
  'use strict';
 | 
						|
 | 
						|
  Polymer({
 | 
						|
    is: 'gr-diff-highlight',
 | 
						|
 | 
						|
    properties: {
 | 
						|
      /** @type {!Array<!Gerrit.HoveredRange>} */
 | 
						|
      commentRanges: {
 | 
						|
        type: Array,
 | 
						|
        notify: true,
 | 
						|
      },
 | 
						|
      loggedIn: Boolean,
 | 
						|
      /**
 | 
						|
       * querySelector can return null, so needs to be nullable.
 | 
						|
       *
 | 
						|
       * @type {?HTMLElement}
 | 
						|
       * */
 | 
						|
      _cachedDiffBuilder: Object,
 | 
						|
      isAttached: Boolean,
 | 
						|
    },
 | 
						|
 | 
						|
    listeners: {
 | 
						|
      'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
 | 
						|
      'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
 | 
						|
      'create-range-comment': '_createRangeComment',
 | 
						|
    },
 | 
						|
 | 
						|
    observers: [
 | 
						|
      '_enableSelectionObserver(loggedIn, isAttached)',
 | 
						|
    ],
 | 
						|
 | 
						|
    get diffBuilder() {
 | 
						|
      if (!this._cachedDiffBuilder) {
 | 
						|
        this._cachedDiffBuilder =
 | 
						|
            Polymer.dom(this).querySelector('gr-diff-builder');
 | 
						|
      }
 | 
						|
      return this._cachedDiffBuilder;
 | 
						|
    },
 | 
						|
 | 
						|
    _enableSelectionObserver(loggedIn, isAttached) {
 | 
						|
      if (loggedIn && isAttached) {
 | 
						|
        this.listen(document, 'selectionchange', '_handleSelectionChange');
 | 
						|
      } else {
 | 
						|
        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    isRangeSelected() {
 | 
						|
      return !!this.$$('gr-selection-action-box');
 | 
						|
    },
 | 
						|
 | 
						|
    _handleSelectionChange() {
 | 
						|
      // Can't use up or down events to handle selection started and/or ended in
 | 
						|
      // in comment threads or outside of diff.
 | 
						|
      // Debounce removeActionBox to give it a chance to react to click/tap.
 | 
						|
      this._removeActionBoxDebounced();
 | 
						|
      this.debounce('selectionChange', this._handleSelection, 200);
 | 
						|
    },
 | 
						|
 | 
						|
    _handleCommentThreadMouseenter(e) {
 | 
						|
      const threadEl = Polymer.dom(e).localTarget;
 | 
						|
      const index = this._indexForThreadEl(threadEl);
 | 
						|
 | 
						|
      if (index !== undefined) {
 | 
						|
        this.set(['commentRanges', index, 'hovering'], true);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    _handleCommentThreadMouseleave(e) {
 | 
						|
      const threadEl = Polymer.dom(e).localTarget;
 | 
						|
      const index = this._indexForThreadEl(threadEl);
 | 
						|
 | 
						|
      if (index !== undefined) {
 | 
						|
        this.set(['commentRanges', index, 'hovering'], false);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    _indexForThreadEl(threadEl) {
 | 
						|
      const side = threadEl.getAttribute('comment-side');
 | 
						|
      const range = JSON.parse(threadEl.getAttribute('range'));
 | 
						|
 | 
						|
      if (!range) return undefined;
 | 
						|
 | 
						|
      return this._indexOfCommentRange(side, range);
 | 
						|
    },
 | 
						|
 | 
						|
    _indexOfCommentRange(side, range) {
 | 
						|
      function rangesEqual(a, b) {
 | 
						|
        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;
 | 
						|
      }
 | 
						|
 | 
						|
      return this.commentRanges.findIndex(commentRange =>
 | 
						|
          commentRange.side === side && rangesEqual(commentRange.range, range));
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * 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).
 | 
						|
     * @return {({
 | 
						|
     *   start: {
 | 
						|
     *     node: Node,
 | 
						|
     *     side: string,
 | 
						|
     *     line: Number,
 | 
						|
     *     column: Number
 | 
						|
     *   },
 | 
						|
     *   end: {
 | 
						|
     *     node: Node,
 | 
						|
     *     side: string,
 | 
						|
     *     line: Number,
 | 
						|
     *     column: Number
 | 
						|
     *   }
 | 
						|
     * })|null|!Object}
 | 
						|
     */
 | 
						|
    _getNormalizedRange() {
 | 
						|
      const selection = window.getSelection();
 | 
						|
      const rangeCount = selection.rangeCount;
 | 
						|
      if (rangeCount === 0) {
 | 
						|
        return null;
 | 
						|
      } else if (rangeCount === 1) {
 | 
						|
        return this._normalizeRange(selection.getRangeAt(0));
 | 
						|
      } else {
 | 
						|
        const startRange = this._normalizeRange(selection.getRangeAt(0));
 | 
						|
        const endRange = this._normalizeRange(
 | 
						|
            selection.getRangeAt(rangeCount - 1));
 | 
						|
        return {
 | 
						|
          start: startRange.start,
 | 
						|
          end: endRange.end,
 | 
						|
        };
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * Normalize a specific DOM Range.
 | 
						|
     * @return {!Object} fixed normalized range
 | 
						|
     */
 | 
						|
    _normalizeRange(domRange) {
 | 
						|
      const range = GrRangeNormalizer.normalize(domRange);
 | 
						|
      return this._fixTripleClickSelection({
 | 
						|
        start: this._normalizeSelectionSide(
 | 
						|
            range.startContainer, range.startOffset),
 | 
						|
        end: this._normalizeSelectionSide(
 | 
						|
            range.endContainer, range.endOffset),
 | 
						|
      }, domRange);
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * Adjust triple click selection for the whole line.
 | 
						|
     * A triple click always results in:
 | 
						|
     * - start.column == end.column == 0
 | 
						|
     * - end.line == start.line + 1
 | 
						|
     *
 | 
						|
     * @param {!Object} range Normalized range, ie column/line numbers
 | 
						|
     * @param {!Range} domRange DOM Range object
 | 
						|
     * @return {!Object} fixed normalized range
 | 
						|
     */
 | 
						|
    _fixTripleClickSelection(range, domRange) {
 | 
						|
      if (!range.start) {
 | 
						|
        // Selection outside of current diff.
 | 
						|
        return range;
 | 
						|
      }
 | 
						|
      const start = range.start;
 | 
						|
      const end = range.end;
 | 
						|
      // Happens when triple click in side-by-side mode with other side empty.
 | 
						|
      const endsAtOtherEmptySide = !end &&
 | 
						|
          domRange.endOffset === 0 &&
 | 
						|
          domRange.endContainer.nodeName === 'TD' &&
 | 
						|
          (domRange.endContainer.classList.contains('left') ||
 | 
						|
           domRange.endContainer.classList.contains('right'));
 | 
						|
      const endsAtBeginningOfNextLine = end &&
 | 
						|
          start.column === 0 &&
 | 
						|
          end.column === 0 &&
 | 
						|
          end.line === start.line + 1;
 | 
						|
      const content = domRange.cloneContents().querySelector('.contentText');
 | 
						|
      const lineLength = content && this._getLength(content) || 0;
 | 
						|
      if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
 | 
						|
        // Move the selection to the end of the previous line.
 | 
						|
        range.end = {
 | 
						|
          node: start.node,
 | 
						|
          column: lineLength,
 | 
						|
          side: start.side,
 | 
						|
          line: start.line,
 | 
						|
        };
 | 
						|
      }
 | 
						|
      return range;
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * Convert DOM Range selection to concrete numbers (line, column, side).
 | 
						|
     * Moves range end if it's not inside td.content.
 | 
						|
     * Returns null if selection end is not valid (outside of diff).
 | 
						|
     *
 | 
						|
     * @param {Node} node td.content child
 | 
						|
     * @param {number} offset offset within node
 | 
						|
     * @return {({
 | 
						|
     *   node: Node,
 | 
						|
     *   side: string,
 | 
						|
     *   line: Number,
 | 
						|
     *   column: Number
 | 
						|
     * }|undefined)}
 | 
						|
     */
 | 
						|
    _normalizeSelectionSide(node, offset) {
 | 
						|
      let column;
 | 
						|
      if (!this.contains(node)) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const lineEl = this.diffBuilder.getLineElByChild(node);
 | 
						|
      if (!lineEl) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const side = this.diffBuilder.getSideByLineEl(lineEl);
 | 
						|
      if (!side) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const line = this.diffBuilder.getLineNumberByChild(lineEl);
 | 
						|
      if (!line) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const contentText = this.diffBuilder.getContentByLineEl(lineEl);
 | 
						|
      if (!contentText) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const contentTd = contentText.parentElement;
 | 
						|
      if (!contentTd.contains(node)) {
 | 
						|
        node = contentText;
 | 
						|
        column = 0;
 | 
						|
      } else {
 | 
						|
        const thread = contentTd.querySelector('gr-diff-comment-thread');
 | 
						|
        if (thread && thread.contains(node)) {
 | 
						|
          column = this._getLength(contentText);
 | 
						|
          node = contentText;
 | 
						|
        } else {
 | 
						|
          column = this._convertOffsetToColumn(node, offset);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      return {
 | 
						|
        node,
 | 
						|
        side,
 | 
						|
        line,
 | 
						|
        column,
 | 
						|
      };
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * The only line in which add a comment tooltip is cut off is the first
 | 
						|
     * line. Even if there is a collapsed section, The first visible line is
 | 
						|
     * in the position where the second line would have been, if not for the
 | 
						|
     * collapsed section, so don't need to worry about this case for
 | 
						|
     * positioning the tooltip.
 | 
						|
     */
 | 
						|
    _positionActionBox(actionBox, startLine, range) {
 | 
						|
      if (startLine > 1) {
 | 
						|
        actionBox.placeAbove(range);
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      actionBox.positionBelow = true;
 | 
						|
      actionBox.placeBelow(range);
 | 
						|
    },
 | 
						|
 | 
						|
    _handleSelection() {
 | 
						|
      const normalizedRange = this._getNormalizedRange();
 | 
						|
      if (!normalizedRange) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const domRange = window.getSelection().getRangeAt(0);
 | 
						|
      /** @type {?} */
 | 
						|
      const start = normalizedRange.start;
 | 
						|
      if (!start) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      const end = normalizedRange.end;
 | 
						|
      if (!end) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      if (start.side !== end.side ||
 | 
						|
          end.line < start.line ||
 | 
						|
          (start.line === end.line && start.column === end.column)) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      // TODO (viktard): Drop empty first and last lines from selection.
 | 
						|
 | 
						|
      const actionBox = document.createElement('gr-selection-action-box');
 | 
						|
      const root = Polymer.dom(this.root);
 | 
						|
      root.insertBefore(actionBox, root.firstElementChild);
 | 
						|
      actionBox.range = {
 | 
						|
        start_line: start.line,
 | 
						|
        start_character: start.column,
 | 
						|
        end_line: end.line,
 | 
						|
        end_character: end.column,
 | 
						|
      };
 | 
						|
      actionBox.side = start.side;
 | 
						|
      if (start.line === end.line) {
 | 
						|
        this._positionActionBox(actionBox, start.line, domRange);
 | 
						|
      } else if (start.node instanceof Text) {
 | 
						|
        if (start.column) {
 | 
						|
          this._positionActionBox(actionBox, start.line,
 | 
						|
              start.node.splitText(start.column));
 | 
						|
        }
 | 
						|
        start.node.parentElement.normalize(); // Undo splitText from above.
 | 
						|
      } else if (start.node.classList.contains('content') &&
 | 
						|
          start.node.firstChild) {
 | 
						|
        this._positionActionBox(actionBox, start.line, start.node.firstChild);
 | 
						|
      } else {
 | 
						|
        this._positionActionBox(actionBox, start.line, start.node);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    _createRangeComment(e) {
 | 
						|
      this._removeActionBox();
 | 
						|
    },
 | 
						|
 | 
						|
    _removeActionBoxDebounced() {
 | 
						|
      this.debounce('removeActionBox', this._removeActionBox, 10);
 | 
						|
    },
 | 
						|
 | 
						|
    _removeActionBox() {
 | 
						|
      const actionBox = this.$$('gr-selection-action-box');
 | 
						|
      if (actionBox) {
 | 
						|
        Polymer.dom(this.root).removeChild(actionBox);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    _convertOffsetToColumn(el, offset) {
 | 
						|
      if (el instanceof Element && el.classList.contains('content')) {
 | 
						|
        return offset;
 | 
						|
      }
 | 
						|
      while (el.previousSibling ||
 | 
						|
          !el.parentElement.classList.contains('content')) {
 | 
						|
        if (el.previousSibling) {
 | 
						|
          el = el.previousSibling;
 | 
						|
          offset += this._getLength(el);
 | 
						|
        } else {
 | 
						|
          el = el.parentElement;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      return offset;
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * Traverse Element from right to left, call callback for each node.
 | 
						|
     * Stops if callback returns true.
 | 
						|
     *
 | 
						|
     * @param {!Element} startNode
 | 
						|
     * @param {function(Node):boolean} callback
 | 
						|
     * @param {Object=} opt_flags If flags.left is true, traverse left.
 | 
						|
     */
 | 
						|
    _traverseContentSiblings(startNode, callback, opt_flags) {
 | 
						|
      const travelLeft = opt_flags && opt_flags.left;
 | 
						|
      let node = startNode;
 | 
						|
      while (node) {
 | 
						|
        if (node instanceof Element &&
 | 
						|
            node.tagName !== 'HL' &&
 | 
						|
            node.tagName !== 'SPAN') {
 | 
						|
          break;
 | 
						|
        }
 | 
						|
        const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
 | 
						|
        if (callback(node)) {
 | 
						|
          break;
 | 
						|
        }
 | 
						|
        node = nextNode;
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get length of a node. If the node is a content node, then only give the
 | 
						|
     * length of its .contentText child.
 | 
						|
     *
 | 
						|
     * @param {?Element} node this is sometimes passed as null.
 | 
						|
     * @return {number}
 | 
						|
     */
 | 
						|
    _getLength(node) {
 | 
						|
      if (node instanceof Element && node.classList.contains('content')) {
 | 
						|
        return this._getLength(node.querySelector('.contentText'));
 | 
						|
      } else {
 | 
						|
        return GrAnnotation.getLength(node);
 | 
						|
      }
 | 
						|
    },
 | 
						|
  });
 | 
						|
})();
 |