diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 8245df754..ee8a7673e 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -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
diff --git a/templates/repo/diff/image_diff.tmpl b/templates/repo/diff/image_diff.tmpl
index eda208d74..01f7e3f8e 100644
--- a/templates/repo/diff/image_diff.tmpl
+++ b/templates/repo/diff/image_diff.tmpl
@@ -1,79 +1,109 @@
{{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName) }}
{{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name) }}
-
-
-
- {{.root.i18n.Tr "repo.diff.file_before"}}
- |
-
- {{.root.i18n.Tr "repo.diff.file_after"}}
- |
-
-
-
- {{if or .file.IsDeleted (not .file.IsCreated)}}
-
-
-
- {{end}}
- |
-
- {{if or .file.IsCreated (not .file.IsDeleted)}}
-
-
-
- {{end}}
- |
-
{{ $imageInfoBase := (call .root.ImageInfoBase .file.OldName) }}
{{ $imageInfoHead := (call .root.ImageInfo .file.Name) }}
-{{if or $imageInfoBase $imageInfoHead }}
+{{if or $imageInfoBase $imageInfoHead}}
-
- {{if $imageInfoBase }}
- {{ $classWidth := "" }}
- {{ $classHeight := "" }}
- {{ $classByteSize := "" }}
- {{if $imageInfoHead}}
- {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}}
- {{ $classWidth = "red" }}
- {{end}}
- {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}}
- {{ $classHeight = "red" }}
- {{end}}
- {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}}
- {{ $classByteSize = "red" }}
- {{end}}
- {{end}}
- {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoBase.Width}}
- |
- {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoBase.Height}}
- |
- {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoBase.ByteSize}}
- {{end}}
- |
-
- {{if $imageInfoHead }}
- {{ $classWidth := "" }}
- {{ $classHeight := "" }}
- {{ $classByteSize := "" }}
- {{if $imageInfoBase}}
- {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}}
- {{ $classWidth = "green" }}
- {{end}}
- {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}}
- {{ $classHeight = "green" }}
- {{end}}
- {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}}
- {{ $classByteSize = "green" }}
- {{end}}
- {{end}}
- {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoHead.Width}}
- |
- {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoHead.Height}}
- |
- {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoHead.ByteSize}}
- {{end}}
- |
-
-{{end}}
+
+
+
+
+
+
+ {{if $imageInfoBase }}
+
+
+
+
+ {{ $classWidth := "" }}
+ {{ $classHeight := "" }}
+ {{ $classByteSize := "" }}
+ {{if $imageInfoHead}}
+ {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}}
+ {{ $classWidth = "red" }}
+ {{end}}
+ {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}}
+ {{ $classHeight = "red" }}
+ {{end}}
+ {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}}
+ {{ $classByteSize = "red" }}
+ {{end}}
+ {{end}}
+ {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoBase.Width}}
+ |
+ {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoBase.Height}}
+ |
+ {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoBase.ByteSize}}
+
+
+ {{end}}
+ {{if $imageInfoHead }}
+
+
+
+
+ {{ $classWidth := "" }}
+ {{ $classHeight := "" }}
+ {{ $classByteSize := "" }}
+ {{if $imageInfoBase}}
+ {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}}
+ {{ $classWidth = "green" }}
+ {{end}}
+ {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}}
+ {{ $classHeight = "green" }}
+ {{end}}
+ {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}}
+ {{ $classByteSize = "green" }}
+ {{end}}
+ {{end}}
+ {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoHead.Width}}
+ |
+ {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoHead.Height}}
+ |
+ {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoHead.ByteSize}}
+
+
+ {{end}}
+
+
+ {{if and $imageInfoBase $imageInfoHead}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{end}}
+
+
+
+ |
+
+{{end}}
\ No newline at end of file
diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.js
new file mode 100644
index 000000000..ce7ce8d2a
--- /dev/null
+++ b/web_src/js/features/imagediff.js
@@ -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();
+ }
+ });
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index b65291a26..30af5dea1 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -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(),
]);
});
diff --git a/web_src/less/features/imagediff.less b/web_src/less/features/imagediff.less
new file mode 100644
index 000000000..f38ea98d7
--- /dev/null
+++ b/web_src/less/features/imagediff.less
@@ -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;
+ }
+ }
+}
diff --git a/web_src/less/index.less b/web_src/less/index.less
index 598693085..cd70eedef 100644
--- a/web_src/less/index.less
+++ b/web_src/less/index.less
@@ -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";