/** * @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 '../../../styles/shared-styles'; import {addListener} from '@polymer/polymer/lib/utils/gestures'; import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; import {PolymerElement} from '@polymer/polymer/polymer-element'; import {htmlTemplate} from './gr-diff-selection_html'; import { normalize, NormalizedRange, } from '../gr-diff-highlight/gr-range-normalizer'; import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util'; import {customElement, property, observe} from '@polymer/decorators'; import {DiffInfo} from '../../../types/common'; import {Side} from '../../../constants/constants'; import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element'; /** * 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', }; interface LinesCache { left: string[] | null; right: string[] | null; } function getNewCache(): LinesCache { return {left: null, right: null}; } @customElement('gr-diff-selection') export class GrDiffSelection extends GestureEventListeners( LegacyElementMixin(PolymerElement) ) { static get template() { return htmlTemplate; } @property({type: Object}) diff?: DiffInfo; @property({type: Object}) _cachedDiffBuilder?: GrDiffBuilderElement; @property({type: Object}) _linesCache: LinesCache = {left: null, right: null}; /** @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 = this.querySelector( 'gr-diff-builder' ) as GrDiffBuilderElement; } return this._cachedDiffBuilder; } @observe('diff') _diffChanged() { this._linesCache = getNewCache(); } _handleDownOnRangeComment(node: Element) { if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') { this._setClasses([ SelectionClass.COMMENT, node.getAttribute('comment-side') === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT, ]); return true; } return false; } _handleDown(e: Event) { const target = e.target; if (!(target instanceof Element)) return; // Handle the down event on comment thread in Polymer 2 const handled = this._handleDownOnRangeComment(target); if (handled) return; const lineEl = this.diffBuilder.getLineElByChild(target); const blameSelected = this._elementDescendedFromClass(target, 'blame'); if (!lineEl && !blameSelected) { return; } const targetClasses = []; if (blameSelected) { targetClasses.push(SelectionClass.BLAME); } else if (lineEl) { const commentSelected = this._elementDescendedFromClass( 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. */ _setClasses(targetClasses: string[]) { // Remove any selection classes that do not belong. for (const className of Object.values(SelectionClass)) { if (!targetClasses.includes(className)) { this.classList.remove(className); } } // 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: Event) { return (dom(e) as EventApi).rootTarget; } /** * Utility function to determine whether an element is a descendant of * another element with the particular className. */ _elementDescendedFromClass(element: Element, className: string) { return descendedFromClass(element, className, this.diffBuilder.diffElement); } _handleCopy(e: ClipboardEvent) { let commentSelected = false; const target = this._getCopyEventTarget(e); if (!(target instanceof Element)) return; if (target instanceof HTMLTextAreaElement) 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) { e.clipboardData.setData('Text', text); e.preventDefault(); } } _getSelection() { const diffHosts = 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 side The side that is selected. * @param commentSelected Whether or not a comment is selected. * @return The selected text. */ _getSelectedText(side: Side, commentSelected: boolean) { const sel = this._getSelection(); if (!sel || sel.rangeCount !== 1) { return ''; // No multi-select support yet. } if (commentSelected) { return this._getCommentLines(sel, side); } const range = normalize(sel.getRangeAt(0)); const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer); if (!startLineEl) return; 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 instanceof HTMLTableCellElement && (range.endContainer.classList.contains('left') || range.endContainer.classList.contains('right')); const startLineDataValue = startLineEl.getAttribute('data-value'); if (!startLineDataValue) return; const startLineNum = parseInt(startLineDataValue, 10); let endLineNum; if (endsAtOtherEmptySide) { endLineNum = startLineNum + 1; } else if (endLineEl) { const endLineDataValue = endLineEl.getAttribute('data-value'); if (endLineDataValue) endLineNum = parseInt(endLineDataValue, 10); } return this._getRangeFromDiff( startLineNum, range.startOffset, endLineNum, range.endOffset, side ); } /** * Query the diff object for the selected lines. */ _getRangeFromDiff( startLineNum: number, startOffset: number, endLineNum: number | undefined, endOffset: number, side: 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 side The side that is currently selected. * @return An array of strings indexed by line number. */ _getDiffLines(side: Side): string[] { if (this._linesCache[side]) { return this._linesCache[side]!; } if (!this.diff) return []; let lines: string[] = []; for (const chunk of this.diff.content) { if (chunk.ab) { lines = lines.concat(chunk.ab); } else if (side === Side.LEFT && chunk.a) { lines = lines.concat(chunk.a); } else if (side === Side.RIGHT && chunk.b) { lines = lines.concat(chunk.b); } } this._linesCache[side] = lines; return lines; } /** * Query the diffElement for comments and check whether they lie inside the * selection range. * * @param sel The selection of the window. * @param side The side that is currently selected. * @return The selected comment text. */ _getCommentLines(sel: Selection, side: Side) { const range = 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 domNode The root DOM node. * @param sel The selection. * @param range The normalized selection range. * @return The text within the selection. */ _getTextContentForRange( domNode: Node, sel: Selection, range: NormalizedRange ) { 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; } } declare global { interface HTMLElementTagNameMap { 'gr-diff-selection': GrDiffSelection; } }