| /** | |
| * @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: { | |
| comments: Object, | |
| loggedIn: Boolean, | |
| /** | |
| * querySelector can return null, so needs to be nullable. | |
| * | |
| * @type {?HTMLElement} | |
| * */ | |
| _cachedDiffBuilder: Object, | |
| isAttached: Boolean, | |
| }, | |
| listeners: { | |
| 'comment-mouse-out': '_handleCommentMouseOut', | |
| 'comment-mouse-over': '_handleCommentMouseOver', | |
| 'create-comment': '_createComment', | |
| }, | |
| 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); | |
| }, | |
| _handleCommentMouseOver(e) { | |
| const comment = e.detail.comment; | |
| if (!comment.range) { return; } | |
| const lineEl = this.diffBuilder.getLineElByChild(e.target); | |
| const side = this.diffBuilder.getSideByLineEl(lineEl); | |
| const index = this._indexOfComment(side, comment); | |
| if (index !== undefined) { | |
| this.set(['comments', side, index, '__hovering'], true); | |
| } | |
| }, | |
| _handleCommentMouseOut(e) { | |
| const comment = e.detail.comment; | |
| if (!comment.range) { return; } | |
| const lineEl = this.diffBuilder.getLineElByChild(e.target); | |
| const side = this.diffBuilder.getSideByLineEl(lineEl); | |
| const index = this._indexOfComment(side, comment); | |
| if (index !== undefined) { | |
| this.set(['comments', side, index, '__hovering'], false); | |
| } | |
| }, | |
| _indexOfComment(side, comment) { | |
| const idProp = comment.id ? 'id' : '__draftID'; | |
| for (let i = 0; i < this.comments[side].length; i++) { | |
| if (comment[idProp] && | |
| this.comments[side][i][idProp] === comment[idProp]) { | |
| return i; | |
| } | |
| } | |
| }, | |
| /** | |
| * 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. | |
| * domRange.endContainer may be one of the following: | |
| * 1) 0 offset at right column's line number cell, or | |
| * 2) 0 offset at left column's line number at the next line. | |
| * Case 1 means left column was triple clicked. | |
| * Case 2 means right column or unified view triple clicked. | |
| * @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; | |
| const endsAtOtherSideLineNum = | |
| domRange.endOffset === 0 && | |
| domRange.endContainer.nodeName === 'TD' && | |
| (domRange.endContainer.classList.contains('left') || | |
| domRange.endContainer.classList.contains('right')); | |
| const endsOnOtherSideStart = endsAtOtherSideLineNum || | |
| end && | |
| end.column === 0 && | |
| end.line === start.line && | |
| end.side != start.side; | |
| const content = domRange.cloneContents().querySelector('.contentText'); | |
| const lineLength = content && this._getLength(content) || 0; | |
| if (lineLength && endsOnOtherSideStart || endsAtOtherSideLineNum) { | |
| // Selection ends at the beginning of the next line. | |
| // 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 = { | |
| startLine: start.line, | |
| startChar: start.column, | |
| endLine: end.line, | |
| endChar: 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); | |
| } | |
| }, | |
| _createComment(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); | |
| } | |
| }, | |
| }); | |
| })(); |