| /** |
| * @license |
| * Copyright 2026 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {when} from 'lit/directives/when.js'; |
| import '../../shared/gr-button/gr-button'; |
| import '../../shared/gr-icon/gr-icon'; |
| import '../../shared/gr-comment-thread/gr-comment-thread'; |
| import type {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread'; |
| import {DiffInfo} from '../../../types/diff'; |
| import {CommentSide, DiffViewMode, Side} from '../../../constants/constants'; |
| import { |
| CommentThread, |
| DraftInfo, |
| EDIT, |
| PARENT, |
| PatchRange, |
| PatchSetNum, |
| RevisionPatchSetNum, |
| } from '../../../types/common'; |
| import {sanitizeHtmlToFragment} from '../../../utils/inner-html-util'; |
| import {resolve} from '../../../models/dependency'; |
| import {browserModelToken} from '../../../models/browser/browser-model'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {ChangeComments} from '../gr-comment-api/gr-comment-api'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {createNew} from '../../../utils/comment-util'; |
| import { |
| getParentIndex, |
| isAParent, |
| isMergeParent, |
| } from '../../../utils/patch-set-util'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {fire, fireAlert} from '../../../utils/event-util'; |
| import { |
| AlignedDiffRow, |
| AlignedDiffRowWithThreads, |
| alignMarkdownTokens, |
| attachThreadsToRows, |
| getThreadDiffSide, |
| parseMarkdownBlocks, |
| reconstructFileContent, |
| } from './markdown-diff-util'; |
| |
| @customElement('gr-diff-markdown-viewer') |
| export class GrDiffMarkdownViewer extends LitElement { |
| @property({type: Object}) diff?: DiffInfo; |
| |
| @property({type: String}) path?: string; |
| |
| @property({type: Object}) patchRange?: PatchRange; |
| |
| @property({type: Array}) threads?: CommentThread[]; |
| |
| @property({type: String}) viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE; |
| |
| @state() alignedRows: AlignedDiffRow[] = []; |
| |
| @state() private internalThreads: CommentThread[] = []; |
| |
| @state() private changeComments?: ChangeComments; |
| |
| @property({type: Boolean}) loggedIn = false; |
| |
| @query('.selection-action-box') |
| private selectionActionBox?: HTMLElement; |
| |
| private selectionActionBoxVisible = false; |
| |
| private selectionBoxPositionBelow = false; |
| |
| private selectionBoxTop = 0; |
| |
| private selectionBoxLeft = 0; |
| |
| private selectedSide?: Side; |
| |
| private selectedLine?: number; |
| |
| private hoveredSide?: Side; |
| |
| private hoveredLine?: number; |
| |
| private readonly getBrowserModel = resolve(this, browserModelToken); |
| |
| private readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| private readonly getUserModel = resolve(this, userModelToken); |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getBrowserModel().diffViewMode$, |
| mode => { |
| if (mode) this.viewMode = mode; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().changeComments$, |
| changeComments => { |
| this.changeComments = changeComments; |
| this.updateInternalThreads(); |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getUserModel().loggedIn$, |
| loggedIn => { |
| this.loggedIn = loggedIn; |
| } |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| document.addEventListener('selectionchange', this.handleSelectionChange); |
| window.addEventListener('keydown', this.handleKeyDown); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| document.removeEventListener('selectionchange', this.handleSelectionChange); |
| window.removeEventListener('keydown', this.handleKeyDown); |
| } |
| |
| get effectiveThreads(): CommentThread[] { |
| return this.threads ?? this.internalThreads; |
| } |
| |
| get filePath(): string | undefined { |
| return this.path ?? this.diff?.meta_b?.name ?? this.diff?.meta_a?.name; |
| } |
| |
| private updateInternalThreads() { |
| if (!this.changeComments || !this.patchRange || !this.filePath) return; |
| this.internalThreads = this.changeComments.getThreadsBySideForFile( |
| {path: this.filePath}, |
| this.patchRange |
| ); |
| } |
| |
| static override get styles() { |
| return [ |
| css` |
| :host { |
| display: block; |
| position: relative; |
| background-color: var(--view-background-color, #ffffff); |
| color: var(--primary-text-color, #202124); |
| font-family: var(--font-family, Roboto, sans-serif); |
| font-size: var(--font-size-normal, 14px); |
| line-height: var(--line-height-normal, 1.5); |
| } |
| .file-level-threads { |
| padding: var(--spacing-m, 12px) var(--spacing-l, 16px); |
| background-color: var(--background-color-secondary, #f8f9fa); |
| border-bottom: 1px solid var(--border-color, #e0e0e0); |
| display: flex; |
| flex-direction: column; |
| gap: var(--spacing-s, 8px); |
| } |
| .file-level-title { |
| font-size: var(--font-size-small, 12px); |
| font-weight: var(--font-weight-bold, 600); |
| color: var(--deemphasized-text-color, #5f6368); |
| text-transform: uppercase; |
| } |
| .column-headers { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| column-gap: var(--spacing-l, 16px); |
| padding: var(--spacing-xs, 4px) var(--spacing-m, 12px); |
| border-bottom: 1px solid var(--border-color, #e0e0e0); |
| background-color: var(--background-color-secondary, #f8f9fa); |
| color: var(--deemphasized-text-color, #5f6368); |
| font-size: var(--font-size-small, 12px); |
| font-weight: var(--font-weight-bold, 600); |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| } |
| .diff-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| column-gap: var(--spacing-l, 16px); |
| row-gap: var(--spacing-m, 12px); |
| padding: var(--spacing-m, 12px); |
| } |
| .diff-cell { |
| position: relative; |
| min-width: 0; |
| overflow-wrap: break-word; |
| padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px); |
| padding-right: 90px; |
| border-left: 3px solid transparent; |
| box-sizing: border-box; |
| display: flex; |
| flex-direction: column; |
| } |
| .diff-cell.empty { |
| background-color: var(--diff-blank-background-color, transparent); |
| min-height: 24px; |
| } |
| .diff-cell.added, |
| .diff-cell.modified-right { |
| border-left-color: var(--positive-green-text-color, #2da44e); |
| background-color: var(--light-add-highlight-color, #d8fed8); |
| border-radius: 0 4px 4px 0; |
| } |
| .diff-cell.deleted, |
| .diff-cell.modified-left { |
| border-left-color: var(--negative-red-text-color, #cf222e); |
| background-color: var(--light-remove-highlight-color, #ffebee); |
| border-radius: 0 4px 4px 0; |
| } |
| .unified-container { |
| box-sizing: border-box; |
| max-width: 100%; |
| padding: var(--spacing-m, 12px) var(--spacing-l, 16px); |
| } |
| .unified-block { |
| position: relative; |
| border-left: 3px solid transparent; |
| box-sizing: border-box; |
| margin-bottom: var(--spacing-s, 8px); |
| overflow-wrap: break-word; |
| padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px); |
| padding-right: 90px; |
| display: flex; |
| flex-direction: column; |
| } |
| .unified-block.added { |
| border-left-color: var(--positive-green-text-color, #2da44e); |
| background-color: var(--light-add-highlight-color, #d8fed8); |
| border-radius: 0 4px 4px 0; |
| } |
| .unified-block.deleted { |
| border-left-color: var(--negative-red-text-color, #cf222e); |
| background-color: var(--light-remove-highlight-color, #ffebee); |
| border-radius: 0 4px 4px 0; |
| } |
| .unified-block.unchanged { |
| border-left-color: transparent; |
| } |
| .cell-action-bar { |
| position: absolute; |
| top: 4px; |
| right: 8px; |
| z-index: 10; |
| pointer-events: none; |
| } |
| .add-comment-btn { |
| pointer-events: auto; |
| opacity: 0; |
| visibility: hidden; |
| transition: opacity 0.15s ease-in-out, background-color 0.15s; |
| background-color: var(--background-color-primary, #ffffff); |
| color: var(--primary-text-color, #202124); |
| border: 1px solid var(--border-color, #dadce0); |
| border-radius: 16px; |
| padding: 2px 8px; |
| font-size: var(--font-size-small, 12px); |
| font-weight: var(--font-weight-medium, 500); |
| box-shadow: var(--elevation-level-1, 0 1px 3px rgba(60, 64, 67, 0.3)); |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| cursor: pointer; |
| user-select: none; |
| line-height: 18px; |
| } |
| .add-comment-btn gr-icon { |
| --gr-icon-size: 16px; |
| color: var(--primary-text-color, #202124); |
| } |
| .diff-cell:hover .add-comment-btn, |
| .unified-block:hover .add-comment-btn { |
| opacity: 1; |
| visibility: visible; |
| } |
| .add-comment-btn:hover { |
| background: linear-gradient( |
| var(--hover-background-color, rgba(161, 194, 250, 0.2)), |
| var(--hover-background-color, rgba(161, 194, 250, 0.2)) |
| ), |
| var(--background-color-primary, #ffffff); |
| box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3)); |
| } |
| .add-comment-btn:active { |
| background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)), |
| var(--background-color-primary, #ffffff); |
| } |
| .selection-action-box { |
| position: absolute; |
| z-index: 500; |
| transform: translate(-50%, -100%); |
| margin-top: -6px; |
| } |
| .selection-action-box.below { |
| transform: translate(-50%, 0); |
| margin-top: 6px; |
| } |
| .selection-comment-btn { |
| background-color: var(--background-color-primary, #ffffff); |
| color: var(--primary-text-color, #202124); |
| border: 1px solid var(--border-color, #dadce0); |
| border-radius: 16px; |
| padding: 4px 12px; |
| font-size: var(--font-size-small, 12px); |
| font-weight: var(--font-weight-medium, 500); |
| box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3)); |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| cursor: pointer; |
| white-space: nowrap; |
| user-select: none; |
| } |
| .selection-comment-btn:hover { |
| background: linear-gradient( |
| var(--hover-background-color, rgba(161, 194, 250, 0.2)), |
| var(--hover-background-color, rgba(161, 194, 250, 0.2)) |
| ), |
| var(--background-color-primary, #ffffff); |
| box-shadow: var(--elevation-level-3, 0 4px 8px rgba(60, 64, 67, 0.3)); |
| } |
| .selection-comment-btn:active { |
| background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)), |
| var(--background-color-primary, #ffffff); |
| } |
| .selection-comment-btn gr-icon { |
| --gr-icon-size: 16px; |
| color: var(--primary-text-color, #202124); |
| } |
| .comment-thread { |
| display: block; |
| max-width: 100%; |
| } |
| .cell-content { |
| min-width: 0; |
| } |
| .threads-container { |
| margin-top: var(--spacing-s, 8px); |
| padding-top: var(--spacing-xs, 4px); |
| display: flex; |
| flex-direction: column; |
| gap: var(--spacing-s, 8px); |
| } |
| .diff-highlight-add, |
| ins { |
| background-color: var(--dark-add-highlight-color, #aaf2aa); |
| text-decoration: none; |
| border-radius: 2px; |
| padding: 1px 2px; |
| } |
| .diff-highlight-del, |
| del { |
| background-color: var(--dark-remove-highlight-color, #ffcdd2); |
| text-decoration: line-through; |
| border-radius: 2px; |
| padding: 1px 2px; |
| } |
| .diff-cell.deleted code, |
| .diff-cell.modified-left code, |
| .unified-block.deleted code, |
| del code { |
| background-color: rgba(0, 0, 0, 0.05); |
| } |
| .diff-cell.added code, |
| .diff-cell.modified-right code, |
| .unified-block.added code, |
| ins code { |
| background-color: rgba(0, 0, 0, 0.05); |
| } |
| .diff-cell.deleted pre, |
| .diff-cell.modified-left pre, |
| .unified-block.deleted pre { |
| background-color: rgba(0, 0, 0, 0.03); |
| border-color: rgba(207, 34, 46, 0.2); |
| } |
| .diff-cell.added pre, |
| .diff-cell.modified-right pre, |
| .unified-block.added pre { |
| background-color: rgba(0, 0, 0, 0.03); |
| border-color: rgba(46, 160, 67, 0.2); |
| } |
| pre .diff-highlight-del { |
| display: inline-block; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| pre .diff-highlight-add { |
| display: inline-block; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| .diff-cell.deleted th, |
| .diff-cell.modified-left th, |
| .unified-block.deleted th { |
| background-color: rgba(0, 0, 0, 0.04); |
| } |
| h1, |
| h2, |
| h3, |
| h4, |
| h5, |
| h6 { |
| margin-top: 0; |
| margin-bottom: var(--spacing-xs, 4px); |
| color: var(--primary-text-color, #202124); |
| } |
| h1 { |
| font-size: 1.6em; |
| border-bottom: 1px solid var(--border-color, #e0e0e0); |
| padding-bottom: 4px; |
| } |
| h2 { |
| font-size: 1.3em; |
| border-bottom: 1px solid var(--border-color, #e0e0e0); |
| padding-bottom: 4px; |
| } |
| h3 { |
| font-size: 1.15em; |
| } |
| p { |
| margin-top: 0; |
| margin-bottom: var(--spacing-xs, 4px); |
| } |
| code { |
| font-family: var(--monospace-font-family, 'Roboto Mono', monospace); |
| font-size: var(--font-size-code, 12px); |
| background-color: var(--background-color-secondary, #f1f3f4); |
| padding: 2px 4px; |
| border-radius: 3px; |
| } |
| pre { |
| background-color: var(--background-color-secondary, #f8f9fa); |
| border: 1px solid var(--border-color, #e0e0e0); |
| border-radius: 4px; |
| padding: var(--spacing-s, 8px); |
| overflow-x: auto; |
| margin: 0; |
| } |
| pre code { |
| background-color: transparent; |
| padding: 0; |
| font-size: var(--font-size-code, 12px); |
| display: block; |
| white-space: pre; |
| } |
| blockquote { |
| border-left: 4px solid var(--border-color, #d0d7de); |
| margin: 0 0 var(--spacing-s, 8px) 0; |
| padding: 0 var(--spacing-m, 12px); |
| color: var(--deemphasized-text-color, #5f6368); |
| } |
| ul, |
| ol { |
| margin-top: 0; |
| margin-bottom: var(--spacing-xs, 4px); |
| padding-left: var(--spacing-xl, 24px); |
| } |
| table { |
| border-collapse: collapse; |
| width: 100%; |
| margin-bottom: var(--spacing-s, 8px); |
| } |
| th, |
| td { |
| border: 1px solid var(--border-color, #e0e0e0); |
| padding: 6px 12px; |
| text-align: left; |
| } |
| th { |
| background-color: var(--background-color-secondary, #f8f9fa); |
| font-weight: var(--font-weight-bold, 600); |
| } |
| `, |
| ]; |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| if (changedProperties.has('diff')) { |
| this.recomputeAlignedRows(); |
| } |
| if ( |
| changedProperties.has('changeComments') || |
| changedProperties.has('patchRange') || |
| changedProperties.has('path') |
| ) { |
| this.updateInternalThreads(); |
| } |
| } |
| |
| private recomputeAlignedRows() { |
| if (!this.diff) { |
| this.alignedRows = []; |
| return; |
| } |
| const textA = reconstructFileContent(this.diff, Side.LEFT); |
| const textB = reconstructFileContent(this.diff, Side.RIGHT); |
| const tokensA = parseMarkdownBlocks(textA); |
| const tokensB = parseMarkdownBlocks(textB); |
| this.alignedRows = alignMarkdownTokens(tokensA, tokensB); |
| } |
| |
| private canCommentOnPatchSetNum(patchNum: PatchSetNum) { |
| if (!this.loggedIn) { |
| fire(this, 'show-auth-required', {}); |
| return false; |
| } |
| if (!this.patchRange) { |
| fireAlert(this, 'Cannot create comment. patchRange undefined.'); |
| return false; |
| } |
| |
| const isEdit = patchNum === EDIT; |
| const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT; |
| |
| if (isEdit) { |
| fireAlert(this, 'You cannot comment on an edit.'); |
| return false; |
| } |
| if (isEditBase) { |
| fireAlert(this, 'You cannot comment on the base patchset of an edit.'); |
| return false; |
| } |
| return true; |
| } |
| |
| private computeParentIndex() { |
| if (!this.patchRange) return null; |
| return isMergeParent(this.patchRange.basePatchNum) |
| ? getParentIndex(this.patchRange.basePatchNum) |
| : null; |
| } |
| |
| createComment(side: Side, lineNum?: number) { |
| if (!this.patchRange) { |
| fireAlert(this, 'Cannot create comment. patchRange undefined.'); |
| return; |
| } |
| |
| const patchNum = |
| side === Side.LEFT && !isAParent(this.patchRange.basePatchNum) |
| ? this.patchRange.basePatchNum |
| : this.patchRange.patchNum; |
| const commentSide = |
| side === Side.LEFT && isAParent(this.patchRange.basePatchNum) |
| ? CommentSide.PARENT |
| : CommentSide.REVISION; |
| |
| if (!this.canCommentOnPatchSetNum(patchNum)) return; |
| const path = this.filePath; |
| assertIsDefined(path, 'path'); |
| |
| const basePath = this.diff?.meta_a?.name; |
| const effectivePath = |
| basePath && side === Side.LEFT && commentSide === CommentSide.REVISION |
| ? basePath |
| : path; |
| |
| let effectiveLine = lineNum; |
| if (effectiveLine === undefined) { |
| const fallbackRow = this.alignedRows.find(r => |
| side === Side.LEFT |
| ? r.leftStartLine !== undefined |
| : r.rightStartLine !== undefined |
| ); |
| effectiveLine = |
| side === Side.LEFT |
| ? fallbackRow?.leftStartLine |
| : fallbackRow?.rightStartLine ?? 1; |
| } |
| |
| const parentIndex = this.computeParentIndex(); |
| const draft: DraftInfo = { |
| ...createNew('', true), |
| patch_set: patchNum as RevisionPatchSetNum, |
| side: commentSide, |
| parent: parentIndex ?? undefined, |
| path: effectivePath, |
| line: typeof effectiveLine === 'number' ? effectiveLine : undefined, |
| }; |
| this.getCommentsModel().addNewDraft(draft); |
| } |
| |
| private handleCellMouseEnter(side: Side, lineNum?: number) { |
| this.hoveredSide = side; |
| this.hoveredLine = lineNum; |
| } |
| |
| private handleCellMouseLeave() { |
| this.hoveredSide = undefined; |
| this.hoveredLine = undefined; |
| } |
| |
| private clearSelection() { |
| (this.renderRoot as ShadowRoot)?.getSelection?.()?.removeAllRanges(); |
| window.getSelection()?.removeAllRanges(); |
| this.selectedSide = undefined; |
| this.selectedLine = undefined; |
| } |
| |
| hasActiveSelection(): boolean { |
| const selection = this.getActiveSelection(); |
| if (!selection || selection.isCollapsed || !selection.rangeCount) { |
| return false; |
| } |
| const range = selection.getRangeAt(0); |
| const container = range.commonAncestorContainer; |
| const elementNode = |
| container.nodeType === Node.TEXT_NODE |
| ? container.parentElement |
| : (container as Element); |
| return !!( |
| elementNode && |
| this.renderRoot.contains(elementNode) && |
| elementNode.closest('.diff-cell, .unified-block') |
| ); |
| } |
| |
| private getActiveSelection(): Selection | null { |
| const shadowSelection = (this.renderRoot as ShadowRoot)?.getSelection?.(); |
| if ( |
| shadowSelection && |
| !shadowSelection.isCollapsed && |
| shadowSelection.rangeCount > 0 |
| ) { |
| return shadowSelection; |
| } |
| const docSelection = window.getSelection(); |
| if ( |
| docSelection && |
| !docSelection.isCollapsed && |
| docSelection.rangeCount > 0 |
| ) { |
| return docSelection; |
| } |
| return null; |
| } |
| |
| private handleSelectionChange = () => { |
| const selection = this.getActiveSelection(); |
| if (!selection || selection.isCollapsed || !selection.rangeCount) { |
| if (this.selectionActionBoxVisible) { |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| } |
| return; |
| } |
| const range = selection.getRangeAt(0); |
| const container = range.commonAncestorContainer; |
| const elementNode = |
| container.nodeType === Node.TEXT_NODE |
| ? container.parentElement |
| : (container as Element); |
| if (!elementNode || !this.renderRoot.contains(elementNode)) { |
| if (this.selectionActionBoxVisible) { |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| } |
| return; |
| } |
| const cell = elementNode.closest('.diff-cell, .unified-block'); |
| if (!cell) { |
| if (this.selectionActionBoxVisible) { |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| } |
| return; |
| } |
| |
| const rangeRect = range.getBoundingClientRect(); |
| const hostRect = this.getBoundingClientRect(); |
| |
| const spaceAbove = rangeRect.top - hostRect.top; |
| if (spaceAbove < 40) { |
| this.selectionBoxTop = rangeRect.bottom - hostRect.top + this.scrollTop; |
| this.selectionBoxPositionBelow = true; |
| } else { |
| this.selectionBoxTop = rangeRect.top - hostRect.top + this.scrollTop; |
| this.selectionBoxPositionBelow = false; |
| } |
| this.selectionBoxLeft = |
| rangeRect.left - hostRect.left + rangeRect.width / 2 + this.scrollLeft; |
| const sideStr = cell.getAttribute('data-side'); |
| this.selectedSide = sideStr === 'left' ? Side.LEFT : Side.RIGHT; |
| const lineStr = cell.getAttribute('data-line'); |
| this.selectedLine = lineStr ? Number(lineStr) : undefined; |
| this.selectionActionBoxVisible = true; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'block'; |
| this.selectionActionBox.style.top = `${this.selectionBoxTop}px`; |
| this.selectionActionBox.style.left = `${this.selectionBoxLeft}px`; |
| this.selectionActionBox.classList.toggle( |
| 'below', |
| this.selectionBoxPositionBelow |
| ); |
| } |
| }; |
| |
| private handleSelectionCommentClick(e: Event) { |
| e.stopPropagation(); |
| if (this.selectedSide !== undefined) { |
| this.createComment(this.selectedSide, this.selectedLine); |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| this.clearSelection(); |
| } |
| } |
| |
| private handleKeyDown = (e: KeyboardEvent) => { |
| if (e.key === 'c' || e.key === 'C') { |
| if (e.ctrlKey || e.metaKey || e.altKey) { |
| return; |
| } |
| const target = e.composedPath()[0] as HTMLElement; |
| if ( |
| target?.tagName === 'INPUT' || |
| target?.tagName === 'TEXTAREA' || |
| target?.tagName === 'GR-TEXTAREA' || |
| target?.isContentEditable |
| ) { |
| return; |
| } |
| if (this.hasActiveSelection() || this.hoveredSide !== undefined) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| this.createCommentFromSelectionOrHover(); |
| } |
| } |
| }; |
| |
| createCommentFromSelectionOrHover() { |
| const selection = this.getActiveSelection(); |
| if (selection && !selection.isCollapsed && selection.rangeCount > 0) { |
| const range = selection.getRangeAt(0); |
| const container = range.commonAncestorContainer; |
| const elementNode = |
| container.nodeType === Node.TEXT_NODE |
| ? container.parentElement |
| : (container as Element); |
| if (elementNode && this.renderRoot.contains(elementNode)) { |
| const cell = elementNode.closest('.diff-cell, .unified-block'); |
| if (cell) { |
| const sideStr = cell.getAttribute('data-side'); |
| const side = sideStr === 'left' ? Side.LEFT : Side.RIGHT; |
| const lineStr = cell.getAttribute('data-line'); |
| const line = lineStr ? Number(lineStr) : undefined; |
| this.createComment(side, line); |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| this.clearSelection(); |
| return; |
| } |
| } |
| } |
| if (this.selectedSide !== undefined) { |
| this.createComment(this.selectedSide, this.selectedLine); |
| this.selectionActionBoxVisible = false; |
| if (this.selectionActionBox) { |
| this.selectionActionBox.style.display = 'none'; |
| } |
| this.clearSelection(); |
| return; |
| } |
| if (this.hoveredSide !== undefined) { |
| this.createComment(this.hoveredSide, this.hoveredLine); |
| return; |
| } |
| const firstRow = this.alignedRows.find(r => r.rightStartLine !== undefined); |
| if (firstRow) { |
| this.createComment(Side.RIGHT, firstRow.rightStartLine); |
| } else { |
| this.createComment(Side.RIGHT, 1); |
| } |
| } |
| |
| async autoSaveDrafts(): Promise<void> { |
| const threadElements = Array.from( |
| this.shadowRoot?.querySelectorAll<GrCommentThread>('gr-comment-thread') ?? |
| [] |
| ); |
| await Promise.all(threadElements.map(thread => thread.autoSave())); |
| } |
| |
| override render() { |
| if (this.viewMode === DiffViewMode.UNIFIED) { |
| return this.renderUnifiedView(); |
| } |
| return this.renderSideBySideView(); |
| } |
| |
| private renderThread(thread: CommentThread, side?: Side) { |
| const diffSide = side ?? getThreadDiffSide(thread, this.patchRange); |
| return html` |
| <gr-comment-thread |
| class="comment-thread" |
| .rootId=${thread.rootId} |
| .thread=${thread} |
| .showPatchset=${false} |
| .showPortedComment=${!!thread.ported} |
| diff-side=${diffSide} |
| line-num=${thread.line ?? 'FILE'} |
| > |
| </gr-comment-thread> |
| `; |
| } |
| |
| private renderCommentButton(side: Side, lineNum?: number) { |
| if (lineNum === undefined) return nothing; |
| return html` |
| <div class="cell-action-bar"> |
| <button |
| type="button" |
| class="add-comment-btn" |
| title="Add comment (line ${lineNum})" |
| aria-label="Add comment (line ${lineNum})" |
| @click=${(e: Event) => { |
| e.stopPropagation(); |
| this.createComment(side, lineNum); |
| }} |
| > |
| <gr-icon icon="add_comment" filled></gr-icon> |
| <span>Comment</span> |
| </button> |
| </div> |
| `; |
| } |
| |
| private renderSelectionActionBox() { |
| return html` |
| <div |
| class="selection-action-box ${this.selectionBoxPositionBelow |
| ? 'below' |
| : ''}" |
| style="display: ${this.selectionActionBoxVisible |
| ? 'block' |
| : 'none'}; top: ${this.selectionBoxTop}px; left: ${this |
| .selectionBoxLeft}px;" |
| > |
| <button |
| type="button" |
| class="selection-comment-btn" |
| @mousedown=${(e: MouseEvent) => { |
| e.preventDefault(); |
| }} |
| @click=${this.handleSelectionCommentClick} |
| > |
| <gr-icon icon="add_comment" filled></gr-icon> |
| <span>Comment (c)</span> |
| </button> |
| </div> |
| `; |
| } |
| |
| private renderSideBySideView() { |
| const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows( |
| this.alignedRows, |
| this.effectiveThreads, |
| this.patchRange |
| ); |
| |
| return html` |
| ${when( |
| fileLevelThreads.length > 0, |
| () => html` |
| <div class="file-level-threads"> |
| <div class="file-level-title">File Comments</div> |
| ${fileLevelThreads.map(t => this.renderThread(t))} |
| </div> |
| ` |
| )} |
| <div class="column-headers"> |
| <div class="column-header left">Base</div> |
| <div class="column-header right">Revision</div> |
| </div> |
| <div class="diff-grid" role="region" aria-label="Rich Markdown Diff"> |
| ${rowsWithThreads.map(row => this.renderRow(row))} |
| </div> |
| ${this.renderSelectionActionBox()} |
| `; |
| } |
| |
| private renderUnifiedView() { |
| const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows( |
| this.alignedRows, |
| this.effectiveThreads, |
| this.patchRange |
| ); |
| |
| return html` |
| ${when( |
| fileLevelThreads.length > 0, |
| () => html` |
| <div class="file-level-threads"> |
| <div class="file-level-title">File Comments</div> |
| ${fileLevelThreads.map(t => this.renderThread(t))} |
| </div> |
| ` |
| )} |
| <div |
| class="unified-container" |
| role="region" |
| aria-label="Rich Markdown Diff" |
| > |
| ${rowsWithThreads.map(row => this.renderUnifiedRow(row))} |
| </div> |
| ${this.renderSelectionActionBox()} |
| `; |
| } |
| |
| private renderUnifiedRow(row: AlignedDiffRowWithThreads) { |
| if (row.status === 'unchanged') { |
| const allThreads = [...row.leftThreads, ...row.rightThreads]; |
| return html` |
| <div |
| class="unified-block unchanged" |
| data-side="right" |
| data-line=${ifDefined(row.rightStartLine)} |
| @mouseenter=${() => |
| this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)} |
| <div class="cell-content"> |
| ${sanitizeHtmlToFragment(row.leftHtml ?? row.rightHtml ?? '')} |
| </div> |
| ${when( |
| allThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${allThreads.map(t => this.renderThread(t))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| if (row.status === 'deleted') { |
| return html` |
| <div |
| class="unified-block deleted" |
| data-side="left" |
| data-line=${ifDefined(row.leftStartLine)} |
| @mouseenter=${() => |
| this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${this.renderCommentButton(Side.LEFT, row.leftStartLine)} |
| <div class="cell-content"> |
| ${sanitizeHtmlToFragment(row.leftHtml!)} |
| </div> |
| ${when( |
| row.leftThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| if (row.status === 'added') { |
| return html` |
| <div |
| class="unified-block added" |
| data-side="right" |
| data-line=${ifDefined(row.rightStartLine)} |
| @mouseenter=${() => |
| this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)} |
| <div class="cell-content"> |
| ${sanitizeHtmlToFragment(row.rightHtml!)} |
| </div> |
| ${when( |
| row.rightThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| // modified: render deleted (base) then added (revision) |
| return html` |
| <div |
| class="unified-block deleted" |
| data-side="left" |
| data-line=${ifDefined(row.leftStartLine)} |
| @mouseenter=${() => |
| this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${this.renderCommentButton(Side.LEFT, row.leftStartLine)} |
| <div class="cell-content">${sanitizeHtmlToFragment(row.leftHtml!)}</div> |
| ${when( |
| row.leftThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))} |
| </div> |
| ` |
| )} |
| </div> |
| <div |
| class="unified-block added" |
| data-side="right" |
| data-line=${ifDefined(row.rightStartLine)} |
| @mouseenter=${() => |
| this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)} |
| <div class="cell-content"> |
| ${sanitizeHtmlToFragment(row.rightHtml!)} |
| </div> |
| ${when( |
| row.rightThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| |
| private renderRow(row: AlignedDiffRowWithThreads) { |
| return html` ${this.renderLeftCell(row)} ${this.renderRightCell(row)} `; |
| } |
| |
| private renderLeftCell(row: AlignedDiffRowWithThreads) { |
| const isDeleted = row.status === 'deleted'; |
| const isModified = row.status === 'modified'; |
| const isEmpty = row.status === 'added' || !row.leftHtml; |
| |
| const classes = { |
| 'diff-cell': true, |
| left: true, |
| deleted: isDeleted, |
| 'modified-left': isModified, |
| empty: isEmpty, |
| }; |
| |
| return html` |
| <div |
| class=${classMap(classes)} |
| data-side="left" |
| data-line=${ifDefined(row.leftStartLine)} |
| @mouseenter=${() => |
| !isEmpty && this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${when(!isEmpty, () => |
| this.renderCommentButton(Side.LEFT, row.leftStartLine) |
| )} |
| <div class="cell-content"> |
| ${when(!isEmpty, () => sanitizeHtmlToFragment(row.leftHtml!))} |
| </div> |
| ${when( |
| row.leftThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| |
| private renderRightCell(row: AlignedDiffRowWithThreads) { |
| const isAdded = row.status === 'added'; |
| const isModified = row.status === 'modified'; |
| const isEmpty = row.status === 'deleted' || !row.rightHtml; |
| |
| const classes = { |
| 'diff-cell': true, |
| right: true, |
| added: isAdded, |
| 'modified-right': isModified, |
| empty: isEmpty, |
| }; |
| |
| return html` |
| <div |
| class=${classMap(classes)} |
| data-side="right" |
| data-line=${ifDefined(row.rightStartLine)} |
| @mouseenter=${() => |
| !isEmpty && this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)} |
| @mouseleave=${() => this.handleCellMouseLeave()} |
| > |
| ${when(!isEmpty, () => |
| this.renderCommentButton(Side.RIGHT, row.rightStartLine) |
| )} |
| <div class="cell-content"> |
| ${when(!isEmpty, () => sanitizeHtmlToFragment(row.rightHtml!))} |
| </div> |
| ${when( |
| row.rightThreads.length > 0, |
| () => html` |
| <div class="threads-container"> |
| ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))} |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-diff-markdown-viewer': GrDiffMarkdownViewer; |
| } |
| } |