Add Image Diff options in Pull Request Diff view (#14450)
Implemented GitHub style image diff
This commit is contained in:
		
							parent
							
								
									42118c6bc8
								
							
						
					
					
						commit
						904a26c57c
					
				
					 6 changed files with 421 additions and 74 deletions
				
			
		|  | @ -1854,6 +1854,9 @@ diff.review.approve = Approve | |||
| diff.review.reject = Request changes | ||||
| diff.committed_by = committed by | ||||
| diff.protected = Protected | ||||
| diff.image.side_by_side = Side by Side | ||||
| diff.image.swipe = Swipe | ||||
| diff.image.overlay = Overlay | ||||
| 
 | ||||
| releases.desc = Track project versions and downloads. | ||||
| release.releases = Releases | ||||
|  |  | |||
|  | @ -1,36 +1,28 @@ | |||
| {{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName)  }} | ||||
| {{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name)  }} | ||||
| 
 | ||||
| <tr> | ||||
|  	<th class="halfwidth center pl-3 pr-2"> | ||||
|  		{{.root.i18n.Tr "repo.diff.file_before"}} | ||||
|  	</th> | ||||
|  	<th class="halfwidth center pl-2 pr-3"> | ||||
|  		{{.root.i18n.Tr "repo.diff.file_after"}} | ||||
|  	</th> | ||||
| </tr> | ||||
| <tr> | ||||
|  	<td class="halfwidth center pl-3 pr-2"> | ||||
|  	    {{if or .file.IsDeleted (not .file.IsCreated)}} | ||||
|             <a href="{{$imagePathOld}}" target="_blank"> | ||||
|                 <img src="{{$imagePathOld}}" class="border red" /> | ||||
|             </a> | ||||
|  	    {{end}} | ||||
|  	</td> | ||||
|  	<td class="halfwidth center pl-2 pr-3"> | ||||
|  	    {{if or .file.IsCreated (not .file.IsDeleted)}} | ||||
| 			<a href="{{$imagePathNew}}" target="_blank"> | ||||
| 				<img src="{{$imagePathNew}}" class="border green" /> | ||||
| 			</a> | ||||
|  	    {{end}} | ||||
|  	</td> | ||||
| </tr> | ||||
| {{ $imageInfoBase := (call .root.ImageInfoBase .file.OldName) }} | ||||
| {{ $imageInfoHead := (call .root.ImageInfo .file.Name) }} | ||||
| {{if or $imageInfoBase $imageInfoHead}} | ||||
| <tr> | ||||
|  	<td class="halfwidth center pl-3 pr-2"> | ||||
| 	<td colspan="2"> | ||||
| 		<div class="image-diff" data-path-before="{{$imagePathOld}}" data-path-after="{{$imagePathNew}}"> | ||||
| 			<div class="ui secondary pointing tabular top attached borderless menu stackable new-menu"> | ||||
| 				<div class="new-menu-inner"> | ||||
| 					<a class="item active" data-tab="diff-side-by-side">{{.root.i18n.Tr "repo.diff.image.side_by_side"}}</a> | ||||
| 					{{if and $imageInfoBase $imageInfoHead}} | ||||
| 					<a class="item" data-tab="diff-swipe">{{.root.i18n.Tr "repo.diff.image.swipe"}}</a> | ||||
| 					<a class="item" data-tab="diff-overlay">{{.root.i18n.Tr "repo.diff.image.overlay"}}</a> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="hide"> | ||||
| 				<div class="ui bottom attached tab image-diff-container active" data-tab="diff-side-by-side"> | ||||
| 					<div class="diff-side-by-side"> | ||||
| 						{{if $imageInfoBase }} | ||||
| 						<span class="side"> | ||||
| 							<p class="side-header">{{.root.i18n.Tr "repo.diff.file_before"}}</p> | ||||
| 							<span class="before-container"><img class="image-before" /></span> | ||||
| 							<p> | ||||
| 								{{ $classWidth := "" }} | ||||
| 								{{ $classHeight := "" }} | ||||
| 								{{ $classByteSize := "" }} | ||||
|  | @ -50,10 +42,14 @@ | |||
| 								{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoBase.Height}}</span> | ||||
| 								 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoBase.ByteSize}}</span> | ||||
| 							</p> | ||||
| 						</span> | ||||
| 						{{end}} | ||||
|  	</td> | ||||
|  	<td class="halfwidth center pl-2 pr-3"> | ||||
| 						{{if $imageInfoHead }} | ||||
| 						<span class="side"> | ||||
| 							<p class="side-header">{{.root.i18n.Tr "repo.diff.file_after"}}</p> | ||||
| 							<span class="after-container"><img class="image-after" /></span> | ||||
| 							<p> | ||||
| 								{{ $classWidth := "" }} | ||||
| 								{{ $classHeight := "" }} | ||||
| 								{{ $classByteSize := "" }} | ||||
|  | @ -73,7 +69,41 @@ | |||
| 								{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoHead.Height}}</span> | ||||
| 								 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoHead.ByteSize}}</span> | ||||
| 							</p> | ||||
| 						</span> | ||||
| 						{{end}} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{{if and $imageInfoBase $imageInfoHead}} | ||||
| 				<div class="ui bottom attached tab image-diff-container" data-tab="diff-swipe"> | ||||
| 					<div class="diff-swipe"> | ||||
| 						<div class="swipe-frame"> | ||||
| 							<span class="before-container"><img class="image-before" /></span> | ||||
| 							<span class="swipe-container"> | ||||
| 								<span class="after-container"><img class="image-after" /></span> | ||||
| 							</span> | ||||
| 							<span class="swipe-bar"> | ||||
| 								<span class="handle top-handle"></span> | ||||
| 								<span class="handle bottom-handle"></span> | ||||
| 							</span> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="ui bottom attached tab image-diff-container" data-tab="diff-overlay"> | ||||
| 					<div class="diff-overlay"> | ||||
| 						<div class="overlay-frame"> | ||||
| 							<div class="ui centered"> | ||||
| 								<input type="range" min="0" max="100" value="50" /> | ||||
| 							</div> | ||||
| 							<span class="before-container"><img class="image-before"/></span> | ||||
| 							<span class="after-container"><img class="image-after" /></span> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 			<div class="ui active centered inline loader"></div> | ||||
| 		</div> | ||||
| 	</td> | ||||
| </tr> | ||||
| {{end}} | ||||
							
								
								
									
										206
									
								
								web_src/js/features/imagediff.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								web_src/js/features/imagediff.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,206 @@ | |||
| export default async function initImageDiff() { | ||||
|   function createContext(image1, image2) { | ||||
|     const size1 = { | ||||
|       width: image1 && image1.width || 0, | ||||
|       height: image1 && image1.height || 0 | ||||
|     }; | ||||
|     const size2 = { | ||||
|       width: image2 && image2.width || 0, | ||||
|       height: image2 && image2.height || 0 | ||||
|     }; | ||||
|     const max = { | ||||
|       width: Math.max(size2.width, size1.width), | ||||
|       height: Math.max(size2.height, size1.height) | ||||
|     }; | ||||
| 
 | ||||
|     return { | ||||
|       image1: $(image1), | ||||
|       image2: $(image2), | ||||
|       size1, | ||||
|       size2, | ||||
|       max, | ||||
|       ratio: [ | ||||
|         Math.floor(max.width - size1.width) / 2, | ||||
|         Math.floor(max.height - size1.height) / 2, | ||||
|         Math.floor(max.width - size2.width) / 2, | ||||
|         Math.floor(max.height - size2.height) / 2 | ||||
|       ] | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   $('.image-diff').each(function() { | ||||
|     const $container = $(this); | ||||
|     const pathAfter = $container.data('path-after'); | ||||
|     const pathBefore = $container.data('path-before'); | ||||
| 
 | ||||
|     const imageInfos = [{ | ||||
|       loaded: false, | ||||
|       path: pathAfter, | ||||
|       $image: $container.find('img.image-after') | ||||
|     }, { | ||||
|       loaded: false, | ||||
|       path: pathBefore, | ||||
|       $image: $container.find('img.image-before') | ||||
|     }]; | ||||
| 
 | ||||
|     for (const info of imageInfos) { | ||||
|       if (info.$image.length > 0) { | ||||
|         info.$image.on('load', () => { | ||||
|           info.loaded = true; | ||||
|           setReadyIfLoaded(); | ||||
|         }); | ||||
|         info.$image.attr('src', info.path); | ||||
|       } else { | ||||
|         info.loaded = true; | ||||
|         setReadyIfLoaded(); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const diffContainerWidth = $container.width() - 300; | ||||
| 
 | ||||
|     function setReadyIfLoaded() { | ||||
|       if (imageInfos[0].loaded && imageInfos[1].loaded) { | ||||
|         initViews(imageInfos[0].$image, imageInfos[1].$image); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     function initViews($imageAfter, $imageBefore) { | ||||
|       initSideBySide(createContext($imageAfter[0], $imageBefore[0])); | ||||
|       if ($imageAfter.length > 0 && $imageBefore.length > 0) { | ||||
|         initSwipe(createContext($imageAfter[1], $imageBefore[1])); | ||||
|         initOverlay(createContext($imageAfter[2], $imageBefore[2])); | ||||
|       } | ||||
| 
 | ||||
|       $container.find('> .loader').hide(); | ||||
|       $container.find('> .hide').removeClass('hide'); | ||||
|     } | ||||
| 
 | ||||
|     function initSideBySide(sizes) { | ||||
|       let factor = 1; | ||||
|       if (sizes.max.width > (diffContainerWidth - 24) / 2) { | ||||
|         factor = (diffContainerWidth - 24) / 2 / sizes.max.width; | ||||
|       } | ||||
| 
 | ||||
|       sizes.image1.css({ | ||||
|         width: sizes.size1.width * factor, | ||||
|         height: sizes.size1.height * factor | ||||
|       }); | ||||
|       sizes.image1.parent().css({ | ||||
|         margin: `${sizes.ratio[1] * factor + 15}px ${sizes.ratio[0] * factor}px ${sizes.ratio[1] * factor}px`, | ||||
|         width: sizes.size1.width * factor + 2, | ||||
|         height: sizes.size1.height * factor + 2 | ||||
|       }); | ||||
|       sizes.image2.css({ | ||||
|         width: sizes.size2.width * factor, | ||||
|         height: sizes.size2.height * factor | ||||
|       }); | ||||
|       sizes.image2.parent().css({ | ||||
|         margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, | ||||
|         width: sizes.size2.width * factor + 2, | ||||
|         height: sizes.size2.height * factor + 2 | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     function initSwipe(sizes) { | ||||
|       let factor = 1; | ||||
|       if (sizes.max.width > diffContainerWidth - 12) { | ||||
|         factor = (diffContainerWidth - 12) / sizes.max.width; | ||||
|       } | ||||
| 
 | ||||
|       sizes.image1.css({ | ||||
|         width: sizes.size1.width * factor, | ||||
|         height: sizes.size1.height * factor | ||||
|       }); | ||||
|       sizes.image1.parent().css({ | ||||
|         margin: `0px ${sizes.ratio[0] * factor}px`, | ||||
|         width: sizes.size1.width * factor + 2, | ||||
|         height: sizes.size1.height * factor + 2 | ||||
|       }); | ||||
|       sizes.image1.parent().parent().css({ | ||||
|         padding: `${sizes.ratio[1] * factor}px 0 0 0`, | ||||
|         width: sizes.max.width * factor + 2 | ||||
|       }); | ||||
|       sizes.image2.css({ | ||||
|         width: sizes.size2.width * factor, | ||||
|         height: sizes.size2.height * factor | ||||
|       }); | ||||
|       sizes.image2.parent().css({ | ||||
|         margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, | ||||
|         width: sizes.size2.width * factor + 2, | ||||
|         height: sizes.size2.height * factor + 2 | ||||
|       }); | ||||
|       sizes.image2.parent().parent().css({ | ||||
|         width: sizes.max.width * factor + 2, | ||||
|         height: sizes.max.height * factor + 2 | ||||
|       }); | ||||
|       $container.find('.diff-swipe').css({ | ||||
|         width: sizes.max.width * factor + 2, | ||||
|         height: sizes.max.height * factor + 4 | ||||
|       }); | ||||
|       $container.find('.swipe-bar').on('mousedown', function(e) { | ||||
|         e.preventDefault(); | ||||
| 
 | ||||
|         const $swipeBar = $(this); | ||||
|         const $swipeFrame = $swipeBar.parent(); | ||||
|         const width = $swipeFrame.width() - $swipeBar.width() - 2; | ||||
| 
 | ||||
|         $(document).on('mousemove.diff-swipe', (e2) => { | ||||
|           e2.preventDefault(); | ||||
| 
 | ||||
|           const value = Math.max(0, Math.min(e2.clientX - $swipeFrame.offset().left, width)); | ||||
| 
 | ||||
|           $swipeBar.css({ | ||||
|             left: value | ||||
|           }); | ||||
|           $container.find('.swipe-container').css({ | ||||
|             width: $swipeFrame.width() - value | ||||
|           }); | ||||
|           $(document).on('mouseup.diff-swipe', () => { | ||||
|             $(document).off('.diff-swipe'); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     function initOverlay(sizes) { | ||||
|       let factor = 1; | ||||
|       if (sizes.max.width > diffContainerWidth - 12) { | ||||
|         factor = (diffContainerWidth - 12) / sizes.max.width; | ||||
|       } | ||||
| 
 | ||||
|       sizes.image1.css({ | ||||
|         width: sizes.size1.width * factor, | ||||
|         height: sizes.size1.height * factor | ||||
|       }); | ||||
|       sizes.image2.css({ | ||||
|         width: sizes.size2.width * factor, | ||||
|         height: sizes.size2.height * factor | ||||
|       }); | ||||
|       sizes.image1.parent().css({ | ||||
|         margin: `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`, | ||||
|         width: sizes.size1.width * factor + 2, | ||||
|         height: sizes.size1.height * factor + 2 | ||||
|       }); | ||||
|       sizes.image2.parent().css({ | ||||
|         margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, | ||||
|         width: sizes.size2.width * factor + 2, | ||||
|         height: sizes.size2.height * factor + 2 | ||||
|       }); | ||||
|       sizes.image2.parent().parent().css({ | ||||
|         width: sizes.max.width * factor + 2, | ||||
|         height: sizes.max.height * factor + 2 | ||||
|       }); | ||||
|       $container.find('.onion-skin').css({ | ||||
|         width: sizes.max.width * factor + 2, | ||||
|         height: sizes.max.height * factor + 4 | ||||
|       }); | ||||
| 
 | ||||
|       const $range = $container.find("input[type='range'"); | ||||
|       const onInput = () => sizes.image1.parent().css({ | ||||
|         opacity: $range.val() / 100 | ||||
|       }); | ||||
|       $range.on('input', onInput); | ||||
|       onInput(); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | @ -20,6 +20,7 @@ import attachTribute from './features/tribute.js'; | |||
| import createColorPicker from './features/colorpicker.js'; | ||||
| import createDropzone from './features/dropzone.js'; | ||||
| import initTableSort from './features/tablesort.js'; | ||||
| import initImageDiff from './features/imagediff.js'; | ||||
| import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; | ||||
| import {initNotificationsTable, initNotificationCount} from './features/notification.js'; | ||||
| import {initStopwatch} from './features/stopwatch.js'; | ||||
|  | @ -2693,6 +2694,7 @@ $(document).ready(async () => { | |||
|     initStopwatch(), | ||||
|     renderMarkdownContent(), | ||||
|     initGithook(), | ||||
|     initImageDiff(), | ||||
|   ]); | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										105
									
								
								web_src/less/features/imagediff.less
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								web_src/less/features/imagediff.less
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,105 @@ | |||
| .image-diff-container { | ||||
|   text-align: center; | ||||
|   padding: 30px 0; | ||||
| 
 | ||||
|   img { | ||||
|     border: 1px solid var(--color-primary-light-7); | ||||
|     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAG0lEQVQYlWN4+vTpf3SMDTAMBYXYBLFpHgoKAeiOf0SGE9kbAAAAAElFTkSuQmCC) right bottom var(--color-primary-light-7); | ||||
|   } | ||||
| 
 | ||||
|   .before-container { | ||||
|     border: 1px solid var(--color-red); | ||||
|     display: block; | ||||
|   } | ||||
| 
 | ||||
|   .after-container { | ||||
|     border: 1px solid var(--color-green); | ||||
|     display: block; | ||||
|   } | ||||
| 
 | ||||
|   .diff-side-by-side { | ||||
|     .side { | ||||
|       display: inline-block; | ||||
|       line-height: 0; | ||||
|       vertical-align: top; | ||||
| 
 | ||||
|       .side-header { | ||||
|         font-weight: bold; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .diff-swipe { | ||||
|     margin: auto; | ||||
| 
 | ||||
|     .swipe-frame { | ||||
|       position: absolute; | ||||
| 
 | ||||
|       .before-container { | ||||
|         position: absolute; | ||||
|       } | ||||
| 
 | ||||
|       .swipe-container { | ||||
|         position: absolute; | ||||
|         right: 0; | ||||
|         display: block; | ||||
|         border-left: 2px solid var(--color-secondary-dark-8); | ||||
|         height: 100%; | ||||
|         overflow: hidden; | ||||
| 
 | ||||
|         .after-container { | ||||
|           position: absolute; | ||||
|           right: 0; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       .swipe-bar { | ||||
|         z-index: 100; | ||||
|         position: absolute; | ||||
|         height: 100%; | ||||
|         top: 0; | ||||
|         left: 0; | ||||
| 
 | ||||
|         .handle { | ||||
|           background: var(--color-secondary-dark-8); | ||||
|           left: -5px; | ||||
|           height: 12px; | ||||
|           width: 12px; | ||||
|           position: absolute; | ||||
|           transform: rotate(45deg); | ||||
|           box-sizing: border-box; | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
|           align-items: center; | ||||
|           cursor: pointer; | ||||
|         } | ||||
| 
 | ||||
|         .top-handle { | ||||
|           top: -12px; | ||||
|         } | ||||
| 
 | ||||
|         .bottom-handle { | ||||
|           bottom: -14px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .diff-overlay { | ||||
|     margin: 0 auto; | ||||
| 
 | ||||
|     .overlay-frame { | ||||
|       margin: 0 auto; | ||||
|       position: relative; | ||||
|     } | ||||
| 
 | ||||
|     .before-container, | ||||
|     .after-container { | ||||
|       position: absolute; | ||||
|     } | ||||
| 
 | ||||
|     input { | ||||
|       width: 300px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -5,6 +5,7 @@ | |||
| @import "./features/gitgraph.less"; | ||||
| @import "./features/animations.less"; | ||||
| @import "./features/heatmap.less"; | ||||
| @import "./features/imagediff.less"; | ||||
| @import "./markdown/mermaid.less"; | ||||
| 
 | ||||
| @import "./chroma/base.less"; | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue