| /** |
| * @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 '../../../scripts/bundled-polymer.js'; |
| |
| import '../../../behaviors/dom-util-behavior/dom-util-behavior.js'; |
| import '../../../styles/shared-styles.js'; |
| import '../../../scripts/util.js'; |
| import '../gr-diff-highlight/gr-range-normalizer.js'; |
| import {addListener} from '@polymer/polymer/lib/utils/gestures.js'; |
| import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js'; |
| import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js'; |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element.js'; |
| import {htmlTemplate} from './gr-diff-selection_html.js'; |
| |
| /** |
| * Possible CSS classes indicating the state of selection. Dynamically added/ |
| * removed based on where the user clicks within the diff. |
| */ |
| const SelectionClass = { |
| COMMENT: 'selected-comment', |
| LEFT: 'selected-left', |
| RIGHT: 'selected-right', |
| BLAME: 'selected-blame', |
| }; |
| |
| const getNewCache = () => { return {left: null, right: null}; }; |
| |
| /** |
| * @appliesMixin Gerrit.DomUtilMixin |
| * @extends Polymer.Element |
| */ |
| class GrDiffSelection extends mixinBehaviors( [ |
| Gerrit.DomUtilBehavior, |
| ], GestureEventListeners( |
| LegacyElementMixin( |
| PolymerElement))) { |
| static get template() { return htmlTemplate; } |
| |
| static get is() { return 'gr-diff-selection'; } |
| |
| static get properties() { |
| return { |
| diff: Object, |
| /** @type {?Object} */ |
| _cachedDiffBuilder: Object, |
| _linesCache: { |
| type: Object, |
| value: getNewCache(), |
| }, |
| }; |
| } |
| |
| static get observers() { |
| return [ |
| '_diffChanged(diff)', |
| ]; |
| } |
| |
| /** @override */ |
| created() { |
| super.created(); |
| this.addEventListener('copy', |
| e => this._handleCopy(e)); |
| addListener(this, 'down', |
| e => this._handleDown(e)); |
| } |
| |
| /** @override */ |
| attached() { |
| super.attached(); |
| this.classList.add(SelectionClass.RIGHT); |
| } |
| |
| get diffBuilder() { |
| if (!this._cachedDiffBuilder) { |
| this._cachedDiffBuilder = |
| dom(this).querySelector('gr-diff-builder'); |
| } |
| return this._cachedDiffBuilder; |
| } |
| |
| _diffChanged() { |
| this._linesCache = getNewCache(); |
| } |
| |
| _handleDownOnRangeComment(node) { |
| if (node && |
| node.nodeName && |
| node.nodeName.toLowerCase() === 'gr-comment-thread') { |
| this._setClasses([ |
| SelectionClass.COMMENT, |
| node.commentSide === 'left' ? |
| SelectionClass.LEFT : |
| SelectionClass.RIGHT, |
| ]); |
| return true; |
| } |
| return false; |
| } |
| |
| _handleDown(e) { |
| // Handle the down event on comment thread in Polymer 2 |
| const handled = this._handleDownOnRangeComment(e.target); |
| if (handled) return; |
| |
| const lineEl = this.diffBuilder.getLineElByChild(e.target); |
| const blameSelected = this._elementDescendedFromClass(e.target, 'blame'); |
| if (!lineEl && !blameSelected) { return; } |
| |
| const targetClasses = []; |
| |
| if (blameSelected) { |
| targetClasses.push(SelectionClass.BLAME); |
| } else { |
| const commentSelected = |
| this._elementDescendedFromClass(e.target, 'gr-comment'); |
| const side = this.diffBuilder.getSideByLineEl(lineEl); |
| |
| targetClasses.push(side === 'left' ? |
| SelectionClass.LEFT : |
| SelectionClass.RIGHT); |
| |
| if (commentSelected) { |
| targetClasses.push(SelectionClass.COMMENT); |
| } |
| } |
| |
| this._setClasses(targetClasses); |
| } |
| |
| /** |
| * Set the provided list of classes on the element, to the exclusion of all |
| * other SelectionClass values. |
| * |
| * @param {!Array<!string>} targetClasses |
| */ |
| _setClasses(targetClasses) { |
| // Remove any selection classes that do not belong. |
| for (const key in SelectionClass) { |
| if (SelectionClass.hasOwnProperty(key)) { |
| const className = SelectionClass[key]; |
| if (!targetClasses.includes(className)) { |
| this.classList.remove(SelectionClass[key]); |
| } |
| } |
| } |
| // Add new selection classes iff they are not already present. |
| for (const _class of targetClasses) { |
| if (!this.classList.contains(_class)) { |
| this.classList.add(_class); |
| } |
| } |
| } |
| |
| _getCopyEventTarget(e) { |
| return dom(e).rootTarget; |
| } |
| |
| /** |
| * Utility function to determine whether an element is a descendant of |
| * another element with the particular className. |
| * |
| * @param {!Element} element |
| * @param {!string} className |
| * @return {boolean} |
| */ |
| _elementDescendedFromClass(element, className) { |
| return this.descendedFromClass(element, className, |
| this.diffBuilder.diffElement); |
| } |
| |
| _handleCopy(e) { |
| let commentSelected = false; |
| const target = this._getCopyEventTarget(e); |
| if (target.type === 'textarea') { return; } |
| if (!this._elementDescendedFromClass(target, 'diff-row')) { return; } |
| if (this.classList.contains(SelectionClass.COMMENT)) { |
| commentSelected = true; |
| } |
| const lineEl = this.diffBuilder.getLineElByChild(target); |
| if (!lineEl) { |
| return; |
| } |
| const side = this.diffBuilder.getSideByLineEl(lineEl); |
| const text = this._getSelectedText(side, commentSelected); |
| if (text) { |
| e.clipboardData.setData('Text', text); |
| e.preventDefault(); |
| } |
| } |
| |
| _getSelection() { |
| const diffHosts = util.querySelectorAll(document.body, 'gr-diff'); |
| if (!diffHosts.length) return window.getSelection(); |
| |
| const curDiffHost = diffHosts.find(diffHost => { |
| if (!diffHost || !diffHost.shadowRoot) return false; |
| const selection = diffHost.shadowRoot.getSelection(); |
| // Pick the one with valid selection: |
| // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type |
| return selection && selection.type !== 'None'; |
| }); |
| |
| return curDiffHost ? |
| curDiffHost.shadowRoot.getSelection(): window.getSelection(); |
| } |
| |
| /** |
| * Get the text of the current selection. If commentSelected is |
| * true, it returns only the text of comments within the selection. |
| * Otherwise it returns the text of the selected diff region. |
| * |
| * @param {!string} side The side that is selected. |
| * @param {boolean} commentSelected Whether or not a comment is selected. |
| * @return {string} The selected text. |
| */ |
| _getSelectedText(side, commentSelected) { |
| const sel = this._getSelection(); |
| if (sel.rangeCount != 1) { |
| return ''; // No multi-select support yet. |
| } |
| if (commentSelected) { |
| return this._getCommentLines(sel, side); |
| } |
| const range = GrRangeNormalizer.normalize(sel.getRangeAt(0)); |
| const startLineEl = |
| this.diffBuilder.getLineElByChild(range.startContainer); |
| const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer); |
| // Happens when triple click in side-by-side mode with other side empty. |
| const endsAtOtherEmptySide = !endLineEl && |
| range.endOffset === 0 && |
| range.endContainer.nodeName === 'TD' && |
| (range.endContainer.classList.contains('left') || |
| range.endContainer.classList.contains('right')); |
| const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10); |
| let endLineNum; |
| if (endsAtOtherEmptySide) { |
| endLineNum = startLineNum + 1; |
| } else if (endLineEl) { |
| endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10); |
| } |
| |
| return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum, |
| range.endOffset, side); |
| } |
| |
| /** |
| * Query the diff object for the selected lines. |
| * |
| * @param {number} startLineNum |
| * @param {number} startOffset |
| * @param {number|undefined} endLineNum Use undefined to get the range |
| * extending to the end of the file. |
| * @param {number} endOffset |
| * @param {!string} side The side that is currently selected. |
| * @return {string} The selected diff text. |
| */ |
| _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) { |
| 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); |
| } |
| return lines.join('\n'); |
| } |
| |
| /** |
| * Query the diff object for the lines from a particular side. |
| * |
| * @param {!string} side The side that is currently selected. |
| * @return {!Array<string>} An array of strings indexed by line number. |
| */ |
| _getDiffLines(side) { |
| if (this._linesCache[side]) { |
| return this._linesCache[side]; |
| } |
| let lines = []; |
| const key = side === 'left' ? 'a' : 'b'; |
| for (const chunk of this.diff.content) { |
| if (chunk.ab) { |
| lines = lines.concat(chunk.ab); |
| } else if (chunk[key]) { |
| lines = lines.concat(chunk[key]); |
| } |
| } |
| this._linesCache[side] = lines; |
| return lines; |
| } |
| |
| /** |
| * Query the diffElement for comments and check whether they lie inside the |
| * selection range. |
| * |
| * @param {!Selection} sel The selection of the window. |
| * @param {!string} side The side that is currently selected. |
| * @return {string} The selected comment text. |
| */ |
| _getCommentLines(sel, side) { |
| const range = GrRangeNormalizer.normalize(sel.getRangeAt(0)); |
| const content = []; |
| // Query the diffElement for comments. |
| const messages = this.diffBuilder.diffElement.querySelectorAll( |
| `.side-by-side [data-side="${side |
| }"] .message *, .unified .message *`); |
| |
| for (let i = 0; i < messages.length; i++) { |
| const el = messages[i]; |
| // Check if the comment element exists inside the selection. |
| if (sel.containsNode(el, true)) { |
| // Padded elements require newlines for accurate spacing. |
| if (el.parentElement.id === 'container' || |
| el.parentElement.nodeName === 'BLOCKQUOTE') { |
| if (content.length && content[content.length - 1] !== '') { |
| content.push(''); |
| } |
| } |
| |
| if (el.id === 'output' && |
| !this._elementDescendedFromClass(el, 'collapsed')) { |
| content.push(this._getTextContentForRange(el, sel, range)); |
| } |
| } |
| } |
| |
| return content.join('\n'); |
| } |
| |
| /** |
| * Given a DOM node, a selection, and a selection range, recursively get all |
| * of the text content within that selection. |
| * Using a domNode that isn't in the selection returns an empty string. |
| * |
| * @param {!Node} domNode The root DOM node. |
| * @param {!Selection} sel The selection. |
| * @param {!Range} range The normalized selection range. |
| * @return {string} The text within the selection. |
| */ |
| _getTextContentForRange(domNode, sel, range) { |
| if (!sel.containsNode(domNode, true)) { return ''; } |
| |
| let text = ''; |
| if (domNode instanceof Text) { |
| text = domNode.textContent; |
| if (domNode === range.endContainer) { |
| text = text.substring(0, range.endOffset); |
| } |
| if (domNode === range.startContainer) { |
| text = text.substring(range.startOffset); |
| } |
| } else { |
| for (const childNode of domNode.childNodes) { |
| text += this._getTextContentForRange(childNode, sel, range); |
| } |
| } |
| return text; |
| } |
| } |
| |
| customElements.define(GrDiffSelection.is, GrDiffSelection); |