/** * @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} */ commentRanges: { type: Array, notify: true, }, loggedIn: Boolean, /** * querySelector can return null, so needs to be nullable. * * @type {?HTMLElement} * */ _cachedDiffBuilder: Object, }, listeners: { 'comment-thread-mouseleave': '_handleCommentThreadMouseleave', 'comment-thread-mouseenter': '_handleCommentThreadMouseenter', 'create-range-comment': '_createRangeComment', }, get diffBuilder() { if (!this._cachedDiffBuilder) { this._cachedDiffBuilder = Polymer.dom(this).querySelector('gr-diff-builder'); } return this._cachedDiffBuilder; }, isRangeSelected() { return !!this.$$('gr-selection-action-box'); }, /** * Determines side/line/range for a DOM selection and shows a tooltip. * * With native shadow DOM, gr-diff-highlight cannot access a selection that * references the DOM elements making up the diff because they are in the * shadow DOM the gr-diff element. For this reason, we listen to the * selectionchange event and retrieve the selection in gr-diff, and then * call this method to process the Selection. * * @param {Selection} selection A DOM Selection living in the shadow DOM of * the diff element. * @param {boolean} isMouseUp If true, this is called due to a mouseup * event, in which case we might want to immediately create a comment. */ handleSelectionChange(selection, isMouseUp) { // 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(selection, isMouseUp), 200); }, _getThreadEl(e) { const path = Polymer.dom(e).path || []; for (const pathEl of path) { if (pathEl.classList.contains('comment-thread')) return pathEl; } return null; }, _handleCommentThreadMouseenter(e) { const threadEl = this._getThreadEl(e); const index = this._indexForThreadEl(threadEl); if (index !== undefined) { this.set(['commentRanges', index, 'hovering'], true); } }, _handleCommentThreadMouseleave(e) { const threadEl = this._getThreadEl(e); 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). * @param {Selection} selection * @return {({ * start: { * node: Node, * side: string, * line: Number, * column: Number * }, * end: { * node: Node, * side: string, * line: Number, * column: Number * } * })|null|!Object} */ _getNormalizedRange(selection) { 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('.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(selection, isMouseUp) { const normalizedRange = this._getNormalizedRange(selection); if (!normalizedRange) { return; } const domRange = selection.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. // If this was a mouse-up event, we create a comment immediately if // the selection is from the end of a line to the start of the next line. // Rather than trying to find the line contents, we just check if the // selection is empty to see that it's at the end of a line. // In this case, we select the entire start line. if (isMouseUp && start.line === end.line - 1 && end.column === 0) { const content = domRange.cloneContents().querySelector('.contentText'); if (this._getLength(content) === 0) { this.fire('create-range-comment', {side: start.side, range: { start_line: start.line, start_character: 0, end_line: start.line, end_character: start.column, }}); return; } } 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); } }, }); })();