blob: a9ec6a2dffd8cb8ae942950080c6efef1e832661 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
import {
descendedFromClass,
parentWithClass,
querySelectorAll,
} from '../../../utils/dom-util';
import {DiffInfo} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {
getLineElByChild,
getSide,
getSideByLineEl,
isThreadEl,
} from '../../diff/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',
};
function selectionClassForSide(side?: Side) {
return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
}
interface LinesCache {
left: string[] | null;
right: string[] | null;
}
function getNewCache(): LinesCache {
return {left: null, right: null};
}
export class GrDiffSelection {
// visible for testing
diff?: DiffInfo;
// visible for testing
diffTable?: HTMLElement;
// visible for testing
linesCache: LinesCache = getNewCache();
init(diff: DiffInfo, diffTable: HTMLElement) {
this.cleanup();
this.diff = diff;
this.diffTable = diffTable;
this.diffTable.classList.add(SelectionClass.RIGHT);
this.diffTable.addEventListener('copy', this.handleCopy);
this.diffTable.addEventListener('mousedown', this.handleDown);
this.linesCache = getNewCache();
}
cleanup() {
if (!this.diffTable) return;
this.diffTable.removeEventListener('copy', this.handleCopy);
this.diffTable.removeEventListener('mousedown', this.handleDown);
}
handleDown = (e: Event) => {
const target = e.target;
if (!(target instanceof Element)) return;
const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
if (commentEl && isThreadEl(commentEl)) {
this.setClasses([
SelectionClass.COMMENT,
selectionClassForSide(getSide(commentEl)),
]);
return;
}
const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
if (blameSelected) {
this.setClasses([SelectionClass.BLAME]);
return;
}
// This works for both, the content and the line number cells.
const lineEl = getLineElByChild(target);
if (lineEl) {
this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
return;
}
};
/**
* Set the provided list of classes on the element, to the exclusion of all
* other SelectionClass values.
*/
setClasses(targetClasses: string[]) {
if (!this.diffTable) return;
// Remove any selection classes that do not belong.
for (const className of Object.values(SelectionClass)) {
if (!targetClasses.includes(className)) {
this.diffTable.classList.remove(className);
}
}
// Add new selection classes iff they are not already present.
for (const targetClass of targetClasses) {
if (!this.diffTable.classList.contains(targetClass)) {
this.diffTable.classList.add(targetClass);
}
}
}
handleCopy = (e: ClipboardEvent) => {
const target = e.composedPath()[0];
if (!(target instanceof Element)) return;
if (target instanceof HTMLTextAreaElement) return;
if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
if (!this.diffTable) return;
if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
const lineEl = getLineElByChild(target);
if (!lineEl) return;
const side = getSideByLineEl(lineEl);
const text = this.getSelectedText(side);
if (text && e.clipboardData) {
e.clipboardData.setData('Text', text);
e.preventDefault();
}
};
getSelection() {
const diffHosts = querySelectorAll(document.body, 'gr-diff');
if (!diffHosts.length) return document.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?.shadowRoot?.getSelection
? curDiffHost.shadowRoot.getSelection()
: document.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) {
const sel = this.getSelection();
if (!sel || sel.rangeCount !== 1) {
return ''; // No multi-select support yet.
}
const range = normalize(sel.getRangeAt(0));
const startLineEl = getLineElByChild(range.startContainer);
if (!startLineEl) return;
const endLineEl = 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 skipChunk = this.diff?.content.find(chunk => chunk.skip);
if (skipChunk) {
startLineNum -= skipChunk.skip!;
if (endLineNum) endLineNum -= skipChunk.skip!;
}
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;
}
}