| /** | |
| * @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; | |
| } | |
| } |