Tom
2014-11-17 b6f47539cd1a1dafe05ffd6fdc40bce4547c479d
Add a blink comparator and pixel difference to image diffs

Pixel difference uses CSS mix-blend-mode, which is supported currently
only on Firefox >= 32 and on Safari >= 7.1. Implementation is behind a
Javascript feature test.

For other browsers, there's a blink comparator.

Code changes:

* ImageDiffHandler now takes the page it's used on as argument. We need
that to get labels. DOM generated is a
little bit different (new controls).

* Diff pages adapted to new constructor of ImageDiffHandler.

* CSS and Javascript changes implementing the new controls, making use
of two new static image resources. Since I felt that the new controls
deserved tooltips, I also gave the opacity slider a tooltip: changed
to <a>, and slider handle changed from <div> to <span>. CSS ensures
everything still displays the same (basically display:inline-block).

* Supplied messages for English, French, and German for the new
tooltips.

Tested on IE8, Safari 6.1.6 & 7.1, Chrome 38, FF 33.1 & FF 3.6.13
2 files added
9 files modified
131 ■■■■ changed files
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties 3 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp_de.properties 3 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp_fr.properties 3 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/BlobDiffPage.java 4 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/CommitDiffPage.java 2 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/ComparePage.java 2 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/ImageDiffHandler.java 32 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/scripts/imgdiff.js 63 ●●●●● patch | view | raw | blame | history
src/main/resources/blink32.png patch | view | raw | blame | history
src/main/resources/gitblit.css 19 ●●●●● patch | view | raw | blame | history
src/main/resources/sub32.png patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -757,3 +757,6 @@
gb.diffRenamedFile = File was renamed from {0}
gb.diffCopiedFile = File was copied from {0}
gb.diffTruncated = Diff truncated after the above file
gb.opacityAdjust = Adjust opacity
gb.blinkComparator = Blink comparator
gb.imgdiffSubtract = Subtract (black = identical)
src/main/java/com/gitblit/wicket/GitBlitWebApp_de.properties
@@ -750,3 +750,6 @@
gb.diffRenamedFile = Datei umbenannt von {0}
gb.diffCopiedFile = Datei kopiert von {0}
gb.diffTruncated = Diff nach obiger Datei abgeschnitten
gb.opacityAdjust = Transparenz
gb.blinkComparator = Blinkkomparator
gb.imgdiffSubtract = Pixeldifferenz (schwarz = identisch)
src/main/java/com/gitblit/wicket/GitBlitWebApp_fr.properties
@@ -679,3 +679,6 @@
gb.diffRenamedFile = Fichier renomm\u00e9 de {0}
gb.diffCopiedFile = Fichier copi\u00e9 de {0}
gb.diffTruncated = Affichage de diff\u00e9rences supprim\u00e9e apr\u00e8s le fichier ci-dessus
gb.opacityAdjust = ajuster l'opacit\u00e9
gb.blinkComparator = Comparateur \u00e0 clignotement
gb.imgdiffSubtract = Diff\u00e9rence (noir = identique)
src/main/java/com/gitblit/wicket/pages/BlobDiffPage.java
@@ -52,7 +52,7 @@
        if (StringUtils.isEmpty(baseObjectId)) {
            // use first parent
            RevCommit parent = commit.getParentCount() == 0 ? null : commit.getParent(0);
            ImageDiffHandler handler = new ImageDiffHandler(getContextUrl(), repositoryName,
            ImageDiffHandler handler = new ImageDiffHandler(this, repositoryName,
                    parent.getName(), commit.getName(), imageExtensions);
            diff = DiffUtils.getDiff(r, commit, blobPath, DiffOutputType.HTML, handler).content;
            if (handler.getImgDiffCount() > 0) {
@@ -63,7 +63,7 @@
        } else {
            // base commit specified
            RevCommit baseCommit = JGitUtils.getCommit(r, baseObjectId);
            ImageDiffHandler handler = new ImageDiffHandler(getContextUrl(), repositoryName,
            ImageDiffHandler handler = new ImageDiffHandler(this, repositoryName,
                    baseCommit.getName(), commit.getName(), imageExtensions);
            diff = DiffUtils.getDiff(r, baseCommit, commit, blobPath, DiffOutputType.HTML, handler).content;
            if (handler.getImgDiffCount() > 0) {
src/main/java/com/gitblit/wicket/pages/CommitDiffPage.java
@@ -82,7 +82,7 @@
        add(new CommitHeaderPanel("commitHeader", repositoryName, commit));
        final List<String> imageExtensions = app().settings().getStrings(Keys.web.imageExtensions);
        final ImageDiffHandler handler = new ImageDiffHandler(getContextUrl(), repositoryName,
        final ImageDiffHandler handler = new ImageDiffHandler(this, repositoryName,
                parents.isEmpty() ? null : parents.get(0), commit.getName(), imageExtensions);
        final DiffOutput diff = DiffUtils.getCommitDiff(r, commit, DiffOutputType.HTML, handler);
        if (handler.getImgDiffCount() > 0) {
src/main/java/com/gitblit/wicket/pages/ComparePage.java
@@ -113,7 +113,7 @@
            toCommitId.setObject(endId);
            final List<String> imageExtensions = app().settings().getStrings(Keys.web.imageExtensions);
            final ImageDiffHandler handler = new ImageDiffHandler(getContextUrl(), repositoryName,
            final ImageDiffHandler handler = new ImageDiffHandler(this, repositoryName,
                    fromCommit.getName(), toCommit.getName(), imageExtensions);
            final DiffOutput diff = DiffUtils.getDiff(r, fromCommit, toCommit, DiffOutputType.HTML, handler);
src/main/java/com/gitblit/wicket/pages/ImageDiffHandler.java
@@ -18,6 +18,7 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.protocol.http.WicketURLEncoder;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffEntry.Side;
@@ -37,14 +38,14 @@
    private final String oldCommitId;
    private final String newCommitId;
    private final String repositoryName;
    private final String baseUrl;
    private final BasePage page;
    private final List<String> imageExtensions;
    private int imgDiffCount = 0;
    public ImageDiffHandler(final String baseUrl, final String repositoryName, final String oldCommitId,
            final String newCommitId, final List<String> imageExtensions) {
        this.baseUrl = baseUrl;
    public ImageDiffHandler(final BasePage page, final String repositoryName, final String oldCommitId, final String newCommitId,
            final List<String> imageExtensions) {
        this.page = page;
        this.repositoryName = repositoryName;
        this.oldCommitId = oldCommitId;
        this.newCommitId = newCommitId;
@@ -81,7 +82,19 @@
                old.appendElement("img").attr("class", "imgdiff-old").attr("id", id).attr("style", "max-width:640px;").attr("src", oldUrl);
                container.appendElement("img").attr("class", "imgdiff").attr("style", "max-width:640px;").attr("src", newUrl);
                wrapper.appendElement("br");
                wrapper.appendElement("div").attr("class", "imgdiff-opa-container").appendElement("div").attr("class", "imgdiff-opa-slider");
                Element controls = wrapper.appendElement("div");
                // Opacity slider
                controls.appendElement("div").attr("class", "imgdiff-opa-container").appendElement("a").attr("class", "imgdiff-opa-slider")
                        .attr("href", "#").attr("title", page.getString("gb.opacityAdjust"));
                // Blink comparator: find Pluto!
                controls.appendElement("a").attr("class", "imgdiff-link imgdiff-blink").attr("href", "#")
                        .attr("title", page.getString("gb.blinkComparator"))
                        .appendElement("img").attr("src", getStaticResourceUrl("blink32.png")).attr("width", "20");
                // Pixel subtraction, initially not displayed, will be shown by imgdiff.js depending on feature test.
                // (Uses CSS mix-blend-mode, which isn't supported on all browsers yet).
                controls.appendElement("a").attr("class", "imgdiff-link imgdiff-subtract").attr("href", "#")
                        .attr("title", page.getString("gb.imgdiffSubtract")).attr("style", "display:none;")
                        .appendElement("img").attr("src", getStaticResourceUrl("sub32.png")).attr("width", "20");
                return builder.toString();
            }
            break;
@@ -118,7 +131,7 @@
                if (ext.equalsIgnoreCase(extension)) {
                    String commitId = Side.NEW.equals(side) ? newCommitId : oldCommitId;
                    if (commitId != null) {
                        return RawServlet.asLink(baseUrl, urlencode(repositoryName), commitId, urlencode(path));
                        return RawServlet.asLink(page.getContextUrl(), urlencode(repositoryName), commitId, urlencode(path));
                    } else {
                        return null;
                    }
@@ -129,6 +142,13 @@
    }
    /**
     * Returns a URL that will fetch the designated static resource from within GitBlit.
     */
    protected String getStaticResourceUrl(String contextRelativePath) {
        return WebApplication.get().getRequestCycleProcessor().getRequestCodingStrategy().rewriteStaticRelativeUrl(contextRelativePath);
    }
    /**
     * Encode a URL component of a {@link RawServlet} URL in the special way that the servlet expects it. Note that
     * the %-encoding used does not encode '&amp;' or '&lt;'. Slashes are not encoded in the result.
     *
src/main/java/com/gitblit/wicket/pages/scripts/imgdiff.js
@@ -22,7 +22,7 @@
 *
 * The styling of the slider is to be done in CSS. Currently recognized options:
 * - initial: <float> clipped to [0..1], default 0
 * - handleClass: <string> to assign to the handle div element created.
 * - handleClass: <string> to assign to the handle span element created.
 * If no handleClass is specified, a very plain default style is assigned.
 */
function rangeSlider(elem, options) {
@@ -30,7 +30,7 @@
    options.initial = Math.min(1.0, Math.max(0, options.initial));
    
    var $elem = $(elem);
    var $handle = $('<div></div>').css({ position: 'absolute', left: 0, cursor: 'ew-resize' });
    var $handle = $('<span></span>').css({ position: 'absolute', left: 0, cursor: 'ew-resize' });
    var $root = $(document.documentElement);
    var $doc = $(document);    
    var lastRatio = options.initial;
@@ -144,6 +144,7 @@
        var opacityAccess = rangeSlider($opacitySlider, {handleClass: 'imgdiff-opa-handle'});
        var $img = $('#' + this.id.substr(this.id.indexOf('-')+1)); // Here we change opacity
        var $div = $img.parent(); // This controls visibility: here we change width.
        var blinking = false;
        
        $overlaySlider.on('slider:pos', function(e, data) {
            var pos = $(data.handle).offset().left;
@@ -167,11 +168,10 @@
            }
        });
        $opacitySlider.on('slider:pos', function(e, data) {
            if ($div.width() <= 0) overlayAccess.moveAuto(1.0); // Make old image visible in a nice way
            if ($div.width() <= 0 && !blinking) overlayAccess.moveAuto(1.0); // Make old image visible in a nice way
            $img.css('opacity', 1.0 - data.ratio);
        });
        $opacitySlider.css('cursor', 'pointer');
        $opacitySlider.on('mousedown', function(e) {
        $opacitySlider.on('click', function(e) {
            var newRatio = (e.pageX - $opacitySlider.offset().left) / $opacitySlider.innerWidth();
            var oldRatio = opacityAccess.getRatio();
            if (newRatio !== oldRatio) {
@@ -184,6 +184,59 @@
            e.preventDefault();
        });
            
        // Blinking before and after images is a good way for the human eye to catch differences.
        var $blinker = $this.find('.imgdiff-blink');
        var initialOpacity = null;
        $blinker.on('click', function(e) {
            if (blinking) {
                window.clearTimeout(blinking);
                $blinker.children('img').first().css('border', '1px solid transparent');
                opacityAccess.setRatio(initialOpacity);
                blinking = null;
            } else {
                $blinker.children('img').first().css('border', '1px solid #AAA');
                initialOpacity = opacityAccess.getRatio();
                var currentOpacity = 1.0;
                function blink() {
                    opacityAccess.setRatio(currentOpacity);
                    currentOpacity = 1.0 - currentOpacity;
                    // Keep frequeny below 2Hz (i.e., delay above 500ms)
                    blinking = window.setTimeout(blink, 600);
                }
                if ($div.width() <= 0) {
                    overlayAccess.moveRatio(1.0, 500, blink);
                } else {
                    blink();
                }
            }
            e.preventDefault();
        });
        // Subtracting before and after images is another good way to detect differences. Result will be
        // black where identical.
        if (typeof $img[0].style.mixBlendMode != 'undefined') {
            // Feature test: does the browser support the mix-blend-mode CSS property from the Compositing
            // and Blending Level 1 spec (http://dev.w3.org/fxtf/compositing-1/#mix-blend-mode )?
            // As of 2014-11, only Firefox >= 32 and Safari >= 7.1 support this. Other browsers will have to
            // make do with the blink comparator only.
            var $sub = $this.find('.imgdiff-subtract');
            $sub.css('display', 'inline-block');
            $sub.on('click', function (e) {
                var curr = $img.css('mix-blend-mode');
                if (curr != 'difference') {
                    curr = 'difference';
                    $sub.children('img').first().css('border', '1px solid #AAA');
                    if ($div.width() <= 0) overlayAccess.moveRatio(1.0, 500);
                    opacityAccess.setRatio(0);
                } else {
                    curr = 'normal';
                    $sub.children('img').first().css('border', '1px solid transparent');
                }
                $img.css('mix-blend-mode', curr);
                e.preventDefault();
            });
        }
    });
}
src/main/resources/blink32.png
src/main/resources/gitblit.css
@@ -1490,10 +1490,12 @@
    user-select: none;
    border: 1px solid #F00;
}
.imgdiff-opa-container {
    display: inline-block;
    width: 200px;
    height: 4px;
    margin: 12px 35px;
    margin: 12px 35px 6px 35px;
    padding: 0;
    position: relative;
    border: 1px solid #888;
@@ -1532,6 +1534,7 @@
}
.imgdiff-opa-handle {
    display: inline-block;
    width: 10px;
    height: 10px;
    position: absolute;
@@ -1549,6 +1552,7 @@
}
.imgdiff-ovr-handle {
    display: inline-block;
    width : 1px;
    height: 100%;
    top: 0px;
@@ -1578,6 +1582,19 @@
    /* With CSS: background-image: radial-gradient(5px at 50% 50%, #444, #888, transparent 5px); */
}
.imgdiff-link {
    margin: 0px 4px;
    text-decoration: none;
    border: none;
}
.imgdiff-link > img {
    border: 1px solid transparent; /* Avoid jumping when we change the border */
    width: 20px;
    height: 20px;
    margin-bottom: 10px;
}
/* End image diffs */
td.changeType {
src/main/resources/sub32.png