| /** |
| * @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 {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/diff'; |
| import {Side} from '../../../constants/constants'; |
| import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element'; |
| import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils'; |
| |
| /** |
| * 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 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}; |
| |
| constructor() { |
| super(); |
| this.addEventListener('copy', e => this._handleCopy(e)); |
| addListener(this, 'down', e => this._handleDown(e)); |
| } |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| 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 (isThreadEl(node)) { |
| this._setClasses([ |
| SelectionClass.COMMENT, |
| getSide(node) === 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?.shadowRoot?.getSelection) 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 = Number(startLineDataValue); |
| let endLineNum; |
| if (endsAtOtherEmptySide) { |
| endLineNum = startLineNum + 1; |
| } else if (endLineEl) { |
| const endLineDataValue = endLineEl.getAttribute('data-value'); |
| if (endLineDataValue) endLineNum = Number(endLineDataValue); |
| } |
| |
| 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; |
| } |
| } |