blob: 08c822654cf8e390ca79ec2525aa924dcbb185bc [file] [log] [blame]
/**
* @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/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 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 (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 || !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 = 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;
}
}