|
| 1 | +import { Component, Inject, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, OnInit } from '@angular/core'; |
| 2 | +import { CommonModule } from '@angular/common'; |
| 3 | +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; |
| 4 | +import { MatButtonModule } from '@angular/material/button'; |
| 5 | +import { MatIconModule } from '@angular/material/icon'; |
| 6 | +import { MatToolbarModule } from '@angular/material/toolbar'; |
| 7 | +import { MatTooltipModule } from '@angular/material/tooltip'; |
| 8 | +import { MatButtonToggleModule } from '@angular/material/button-toggle'; |
| 9 | +import { BreakpointObserver } from '@angular/cdk/layout'; |
| 10 | +import { diffLines, Change } from 'diff'; |
| 11 | +import { I18nService } from '../services/i18n.service'; |
| 12 | +import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; |
| 13 | + |
| 14 | +export interface DiffDialogData { |
| 15 | + keyName: string; |
| 16 | + fieldName?: string; |
| 17 | + oldValue: string; |
| 18 | + newValue: string; |
| 19 | +} |
| 20 | + |
| 21 | +interface DiffBlock { |
| 22 | + type: 'added' | 'removed' | 'unchanged' | 'collapse'; |
| 23 | + lines: string[]; |
| 24 | + collapsedCount?: number; |
| 25 | + expanded?: boolean; |
| 26 | +} |
| 27 | + |
| 28 | +const CONTEXT_LINES = 3; |
| 29 | + |
| 30 | +@Component({ |
| 31 | + selector: 'p3xr-diff-dialog', |
| 32 | + standalone: true, |
| 33 | + imports: [ |
| 34 | + CommonModule, MatDialogModule, MatButtonModule, MatIconModule, |
| 35 | + MatToolbarModule, MatTooltipModule, MatButtonToggleModule, |
| 36 | + DialogCancelButtonComponent, |
| 37 | + ], |
| 38 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 39 | + encapsulation: ViewEncapsulation.None, |
| 40 | + template: ` |
| 41 | + <mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong"> |
| 42 | + <span mat-dialog-title class="p3xr-dialog-title p3xr-dialog-title-with-icon" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> |
| 43 | + <mat-icon>difference</mat-icon> |
| 44 | + <span>{{ diffStrings().reviewChanges || 'Review changes' }}</span> |
| 45 | + </span> |
| 46 | + <span style="flex: 1;"></span> |
| 47 | + <mat-button-toggle-group [value]="mode()" (change)="mode.set($event.value)" class="p3xr-diff-toggle" [hideSingleSelectionIndicator]="true"> |
| 48 | + <mat-button-toggle value="inline">{{ diffStrings().inline || 'Inline' }}</mat-button-toggle> |
| 49 | + <mat-button-toggle value="side-by-side">{{ diffStrings().sideBySide || 'Side by side' }}</mat-button-toggle> |
| 50 | + </mat-button-toggle-group> |
| 51 | + <span class="p3xr-diff-summary-header"> |
| 52 | + <span class="p3xr-diff-count-add">+{{ additions() }}</span> {{ diffStrings().additions || 'additions' }}, |
| 53 | + <span class="p3xr-diff-count-del">-{{ deletions() }}</span> {{ diffStrings().deletions || 'deletions' }} |
| 54 | + </span> |
| 55 | + <button mat-icon-button (click)="dialogRef.close(false)"><mat-icon>close</mat-icon></button> |
| 56 | + </mat-toolbar> |
| 57 | +
|
| 58 | + <mat-dialog-content class="p3xr-dialog-content p3xr-diff-content" [class.p3xr-diff-sbs]="mode() === 'side-by-side'"> |
| 59 | + @if (mode() === 'inline') { |
| 60 | + @for (block of blocks(); track $index) { |
| 61 | + @if (block.type === 'collapse' && !block.expanded) { |
| 62 | + <div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div> |
| 63 | + } @else { |
| 64 | + @for (line of block.lines; track $index) { |
| 65 | + <div class="p3xr-diff-line" [class.p3xr-diff-added]="block.type === 'added'" [class.p3xr-diff-removed]="block.type === 'removed'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'"> |
| 66 | + <span class="p3xr-diff-prefix">{{ block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' ' }}</span>{{ line }} |
| 67 | + </div> |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + } @else { |
| 72 | + <div class="p3xr-diff-side"> |
| 73 | + <div class="p3xr-diff-side-header">{{ diffStrings().before || 'Before' }}</div> |
| 74 | + @for (block of blocks(); track $index) { |
| 75 | + @if (block.type === 'collapse' && !block.expanded) { |
| 76 | + <div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div> |
| 77 | + } @else if (block.type !== 'added') { |
| 78 | + @for (line of block.lines; track $index) { |
| 79 | + <div class="p3xr-diff-line" [class.p3xr-diff-removed]="block.type === 'removed'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'">{{ line }}</div> |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + </div> |
| 84 | + <div class="p3xr-diff-side"> |
| 85 | + <div class="p3xr-diff-side-header">{{ diffStrings().after || 'After' }}</div> |
| 86 | + @for (block of blocks(); track $index) { |
| 87 | + @if (block.type === 'collapse' && !block.expanded) { |
| 88 | + <div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div> |
| 89 | + } @else if (block.type !== 'removed') { |
| 90 | + @for (line of block.lines; track $index) { |
| 91 | + <div class="p3xr-diff-line" [class.p3xr-diff-added]="block.type === 'added'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'">{{ line }}</div> |
| 92 | + } |
| 93 | + } |
| 94 | + } |
| 95 | + </div> |
| 96 | + } |
| 97 | + </mat-dialog-content> |
| 98 | +
|
| 99 | + <mat-dialog-actions class="p3xr-dialog-actions"> |
| 100 | + <p3xr-dialog-cancel (cancel)="dialogRef.close(false)"></p3xr-dialog-cancel> |
| 101 | + <button mat-raised-button class="btn-primary" (click)="dialogRef.close(true)" |
| 102 | + [matTooltip]="strings().intention?.save || 'Save'" [matTooltipDisabled]="isWide"> |
| 103 | + <mat-icon>save</mat-icon> |
| 104 | + @if (isWide) { <span>{{ strings().intention?.save || 'Save' }}</span> } |
| 105 | + </button> |
| 106 | + </mat-dialog-actions> |
| 107 | + `, |
| 108 | + styles: [` |
| 109 | + .p3xr-diff-content { |
| 110 | + font-family: 'Roboto Mono', monospace; |
| 111 | + font-size: 13px; |
| 112 | + padding: 0 !important; |
| 113 | + min-height: 200px; |
| 114 | + max-height: 60vh; |
| 115 | + overflow: auto; |
| 116 | + } |
| 117 | + .p3xr-diff-sbs { |
| 118 | + display: grid; |
| 119 | + grid-template-columns: 1fr 1fr; |
| 120 | + } |
| 121 | + .p3xr-diff-side { |
| 122 | + overflow: auto; |
| 123 | + &:first-child { border-right: 1px solid rgba(128,128,128,0.2); } |
| 124 | + } |
| 125 | + .p3xr-diff-side-header { |
| 126 | + padding: 4px 8px; |
| 127 | + font-weight: 500; |
| 128 | + position: sticky; |
| 129 | + top: 0; |
| 130 | + z-index: 1; |
| 131 | + border-bottom: 1px solid rgba(128,128,128,0.2); |
| 132 | + background: var(--p3xr-content-bg, inherit); |
| 133 | + } |
| 134 | + .p3xr-diff-line { |
| 135 | + padding: 1px 8px; |
| 136 | + white-space: pre-wrap; |
| 137 | + word-break: break-all; |
| 138 | + } |
| 139 | + .p3xr-diff-prefix { |
| 140 | + display: inline-block; |
| 141 | + width: 16px; |
| 142 | + font-weight: 700; |
| 143 | + user-select: none; |
| 144 | + } |
| 145 | + .p3xr-diff-added { background: rgba(76,175,80,0.12); } |
| 146 | + .p3xr-diff-removed { background: rgba(244,67,54,0.12); } |
| 147 | + .p3xr-diff-unchanged { opacity: 0.6; } |
| 148 | + .p3xr-diff-collapse { |
| 149 | + padding: 4px 8px; |
| 150 | + opacity: 0.4; |
| 151 | + font-style: italic; |
| 152 | + cursor: pointer; |
| 153 | + &:hover { opacity: 0.7; } |
| 154 | + } |
| 155 | + .p3xr-diff-toggle { |
| 156 | + height: 28px; |
| 157 | + margin-right: 4px; |
| 158 | + border-radius: 4px !important; |
| 159 | + overflow: hidden; |
| 160 | + border: 1px solid rgba(255,255,255,0.3) !important; |
| 161 | +
|
| 162 | + .mat-button-toggle { |
| 163 | + height: 28px; |
| 164 | + font-size: 12px; |
| 165 | + border: none !important; |
| 166 | + border-left: 1px solid rgba(255,255,255,0.3) !important; |
| 167 | + border-radius: 0 !important; |
| 168 | + background: transparent; |
| 169 | + color: rgba(255,255,255,0.7); |
| 170 | + } |
| 171 | + .mat-button-toggle:first-child { |
| 172 | + border-left: none !important; |
| 173 | + } |
| 174 | + .mat-button-toggle-checked { |
| 175 | + background: rgba(255,255,255,0.15) !important; |
| 176 | + color: rgba(255,255,255,0.95) !important; |
| 177 | + } |
| 178 | + .mat-button-toggle-button { |
| 179 | + height: 28px; |
| 180 | + } |
| 181 | + .mat-button-toggle-label-content { |
| 182 | + line-height: 28px !important; |
| 183 | + padding: 0 10px !important; |
| 184 | + } |
| 185 | + .mat-pseudo-checkbox, |
| 186 | + .mdc-button__icon { |
| 187 | + display: none !important; |
| 188 | + } |
| 189 | + .mat-button-toggle-button { |
| 190 | + padding: 0 !important; |
| 191 | + } |
| 192 | + } |
| 193 | + .p3xr-diff-summary-header { |
| 194 | + font-size: 12px; opacity: 0.8; white-space: nowrap; margin-left: 8px; margin-right: 4px; |
| 195 | + } |
| 196 | + .p3xr-diff-count-add { color: #81c784; font-weight: 700; } |
| 197 | + .p3xr-diff-count-del { color: #ef9a9a; font-weight: 700; } |
| 198 | + `], |
| 199 | +}) |
| 200 | +export class DiffDialogComponent implements OnInit { |
| 201 | + readonly strings; |
| 202 | + readonly diffStrings; |
| 203 | + readonly mode = signal<'inline' | 'side-by-side'>('inline'); |
| 204 | + readonly blocks = signal<DiffBlock[]>([]); |
| 205 | + isWide = true; |
| 206 | + |
| 207 | + private rawChanges: Change[]; |
| 208 | + readonly additions; |
| 209 | + readonly deletions; |
| 210 | + |
| 211 | + constructor( |
| 212 | + @Inject(MAT_DIALOG_DATA) public data: DiffDialogData, |
| 213 | + @Inject(MatDialogRef) public dialogRef: MatDialogRef<DiffDialogComponent>, |
| 214 | + @Inject(I18nService) private i18n: I18nService, |
| 215 | + @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, |
| 216 | + ) { |
| 217 | + this.strings = this.i18n.strings; |
| 218 | + this.diffStrings = computed(() => this.strings()?.diff || {}); |
| 219 | + this.rawChanges = diffLines(data.oldValue, data.newValue); |
| 220 | + this.additions = computed(() => this.rawChanges.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0)); |
| 221 | + this.deletions = computed(() => this.rawChanges.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0)); |
| 222 | + } |
| 223 | + |
| 224 | + ngOnInit(): void { |
| 225 | + this.blocks.set(this.buildBlocks()); |
| 226 | + this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { |
| 227 | + this.isWide = r.matches; |
| 228 | + }); |
| 229 | + } |
| 230 | + |
| 231 | + expandBlock(index: number): void { |
| 232 | + const updated = [...this.blocks()]; |
| 233 | + const block = { ...updated[index] }; |
| 234 | + block.expanded = true; |
| 235 | + block.type = 'unchanged'; |
| 236 | + updated[index] = block; |
| 237 | + this.blocks.set(updated); |
| 238 | + } |
| 239 | + |
| 240 | + private buildBlocks(): DiffBlock[] { |
| 241 | + const blocks: DiffBlock[] = []; |
| 242 | + for (const change of this.rawChanges) { |
| 243 | + const lines = change.value.replace(/\n$/, '').split('\n'); |
| 244 | + if (change.added) { |
| 245 | + blocks.push({ type: 'added', lines }); |
| 246 | + } else if (change.removed) { |
| 247 | + blocks.push({ type: 'removed', lines }); |
| 248 | + } else { |
| 249 | + if (lines.length <= CONTEXT_LINES * 2 + 1) { |
| 250 | + blocks.push({ type: 'unchanged', lines }); |
| 251 | + } else { |
| 252 | + blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) }); |
| 253 | + const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES); |
| 254 | + blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length }); |
| 255 | + blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) }); |
| 256 | + } |
| 257 | + } |
| 258 | + } |
| 259 | + return blocks; |
| 260 | + } |
| 261 | +} |
0 commit comments