| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../../styles/shared-styles'; |
| import { |
| normalize, |
| NormalizedRange, |
| } from '../gr-diff-highlight/gr-range-normalizer'; |
| import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util'; |
| import {DiffInfo} from '../../../types/diff'; |
| import {Side} from '../../../constants/constants'; |
| import { |
| getLineElByChild, |
| getSide, |
| getSideByLineEl, |
| isThreadEl, |
| } from '../gr-diff/gr-diff-utils'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| |
| /** |
| * 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}; |
| } |
| |
| 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); |
| } |
| |
| 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; |
| const handled = this.handleDownOnRangeComment(target); |
| if (handled) return; |
| const lineEl = getLineElByChild(target); |
| const blameSelected = descendedFromClass(target, 'blame', this.diffTable); |
| if (!lineEl && !blameSelected) { |
| return; |
| } |
| |
| const targetClasses = []; |
| |
| if (blameSelected) { |
| targetClasses.push(SelectionClass.BLAME); |
| } else if (lineEl) { |
| const commentSelected = descendedFromClass( |
| target, |
| 'gr-comment', |
| this.diffTable |
| ); |
| const side = 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[]) { |
| 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) => { |
| let commentSelected = false; |
| 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)) { |
| commentSelected = true; |
| } |
| const lineEl = getLineElByChild(target); |
| if (!lineEl) return; |
| const side = 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 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, 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 = 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; |
| } |
| |
| /** |
| * 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 = []; |
| assertIsDefined(this.diffTable, 'diffTable'); |
| const messages = this.diffTable.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' && |
| !descendedFromClass(el, 'collapsed', this.diffTable) |
| ) { |
| 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; |
| } |
| } |