// 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'; /** * 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}; }; Polymer({ is: 'gr-diff-selection', properties: { diff: Object, /** @type {?Object} */ _cachedDiffBuilder: Object, _linesCache: { type: Object, value: getNewCache(), }, }, observers: [ '_diffChanged(diff)', ], listeners: { copy: '_handleCopy', down: '_handleDown', }, behaviors: [ Gerrit.DomUtilBehavior, ], attached() { this.classList.add(SelectionClass.RIGHT); }, get diffBuilder() { if (!this._cachedDiffBuilder) { this._cachedDiffBuilder = Polymer.dom(this).querySelector('gr-diff-builder'); } return this._cachedDiffBuilder; }, _diffChanged() { this._linesCache = getNewCache(); }, _handleDown(e) { 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-diff-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 Polymer.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(); } }, /** * Get the text of the current window 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 = window.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); const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10); const endLineNum = endLineEl === null ? undefined : 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; }, }); })();