Tom
2014-11-14 12de8838924c8f14e803bf090da34fe0ef5de102
Javascript-based sliders styled with CSS

This works better for small images. The previous CSS-resize based
attempt worked reasonably well, but had two problems on WebKit
(Safari):

1. For very small images the red resize handle would overlap the image
itself. In that case, the image became un-draggable as soon as the
opacity was reduced below 1.0.

2. Safari apparently doesn't send mousemove events during a CSS
resize, so the opacity was changed only on mouseup.

Both observed on Safari 6.1.6 and 7.1. FF 33.1 had no problems.

Therefore I've switched to a Javascript slider. Since I didn't find
any that was simple, did not require HTML 5, appeared to be well
maintained, had a bug tracker and not too many outstanding bug reports,
didn't pull in umpteen other dependencies, didn't suffer from feature
bloat, was compatible with jQuery 1.7.1, and was freely licensed, I
ended up writing my own.

imgdiff.js contains a small Javascript slider (only horizontal) that is
styled completely in CSS. It reports ratios in the range [0..1] and
fires nice jQuery events 'slider:pos' on value changes. Base element
is a plain div that is positioned. It's not a general-purpose do-it-all
slider, but it's small, simple, and works for what we need it.

(imgdiff.js also sets up the ese sliders on the diff pages.)
3 files modified
333 ■■■■ changed files
src/main/java/com/gitblit/wicket/pages/ImageDiffHandler.java 13 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/scripts/imgdiff.js 143 ●●●●● patch | view | raw | blame | history
src/main/resources/gitblit.css 177 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/ImageDiffHandler.java
@@ -67,8 +67,9 @@
                imgDiffCount++;
                String id = "imgdiff" + imgDiffCount;
                HtmlBuilder builder = new HtmlBuilder("div");
                Element container = builder.root().attr("align", "center").appendElement("div").attr("class", "imgdiff");
                Element resizeable = container.appendElement("div").attr("class", "imgdiff-left");
                Element wrapper = builder.root().attr("class", "imgdiff-container").attr("id", "imgdiff-" + id);
                Element container = wrapper.appendElement("div").attr("class", "imgdiff-ovr-slider").appendElement("div").attr("class", "imgdiff");
                Element old = container.appendElement("div").attr("class", "imgdiff-left");
                // style='max-width:640px;' is necessary for ensuring that the browser limits large images
                // to some reasonable width, and to override the "img { max-width: 100%; }" from bootstrap.css,
                // which would scale the left image to the width of its resizeable container, which isn't what
@@ -77,12 +78,10 @@
                // is too wide.
                // XXX: Maybe add a max-height, too, to limit portrait-oriented images to some reasonable height?
                // (Like a 300x10000px image...)
                resizeable.appendElement("img").attr("class", "imgdiff-left").attr("id", id).attr("style", "max-width:640px;").attr("src", oldUrl);
                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);
                builder.root().appendElement("br");
                Element slider = builder.root().appendElement("div").attr("class", "imgdiff-slider");
                slider.appendElement("div").attr("class", "imgdiff-slider-resizeable").attr("id", "slider-" + id)
                    .appendElement("div").attr("class", "imgdiff-slider-left");
                wrapper.appendElement("br");
                wrapper.appendElement("div").attr("class", "imgdiff-opa-container").appendElement("div").attr("class", "imgdiff-opa-slider");
                return builder.toString();
            }
            break;
src/main/java/com/gitblit/wicket/pages/scripts/imgdiff.js
@@ -13,18 +13,135 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
jQuery(function () {
    // Runs on jQuery's document.ready and sets up the scroll event handlers for all image diffs.
    jQuery(".imgdiff-slider-resizeable").each(function () {
        var $el = jQuery(this);
        var $img = jQuery('#' + this.id.substr(this.id.indexOf('-') + 1));
        function fade() {
            var w = Math.max(0, $el.width() - 18); // Must correspond to CSS: 18 px is handle width, 400 px is slider width
            w = Math.max(0, 1.0 - w / 400.0);
            $img.css("opacity", w);
(function($) {
/**
 * Sets up elem as a slider; returns an access object. Elem must be positioned!
 * Note that the element may contain other elements; this is used for instance
 * for the image diff overlay slider.
 *
 * 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.
 * If no handleClass is specified, a very plain default style is assigned.
 */
function rangeSlider(elem, options) {
    options = $.extend({ initial : 0 }, options || {});
    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 $root = $(document.documentElement);
    var $doc = $(document);
    var lastRatio = options.initial;
    /** Mousemove event handler to track the mouse and move the slider. Generates slider:pos events. */
    function track(e) {
        var pos = $elem.offset().left;
        var width = $elem.width();
        var handleWidth = $handle.width();
        var range = width - handleWidth;
        if (range <= 0) return;
        var delta = Math.min(range, Math.max (0, e.pageX - pos - handleWidth / 2));
        lastRatio = delta / range;
        $handle.css('left', "" + (delta * 100 / width) + '%');
        $elem.trigger('slider:pos', { ratio: lastRatio, handle: $handle[0] });
    }
    /** Mouseup event handler to stop mouse tracking. */
    function end(e) {
        $doc.off('mousemove', track);
        $doc.off('mouseup', end);
        $root.removeClass('no-select');
    }
    /** Snaps the slider to the given ratio and generates a slider:pos event with the new ratio. */
    function setTo(ratio) {
        var w = $elem.width();
        if (w <= 0 || $elem.is(':hidden')) return;
        lastRatio = Math.min( 1.0, Math.max(0, ratio));
        $handle.css('left', "" + Math.max(0, 100 * (lastRatio * (w - $handle.width())) / w) + '%');
        $elem.trigger('slider:pos', { ratio: lastRatio, handle: $handle[0] });
    }
    /**
     * Moves the slider to the given ratio, clipped to [0..1], in duration milliseconds.
     * Generates slider:pos events during the animation. If duration === 0, same as setTo.
     * Default duration is 500ms.
     */
    function moveTo(ratio, duration) {
        ratio = Math.min(1.0, Math.max(0, ratio));
        if (ratio === lastRatio) return;
        if (typeof duration == 'undefined') duration = 500;
        if (duration === 0) {
            setTo(ratio);
        } else {
            var target = ratio * ($elem.width() - $handle.width());
            if (ratio > lastRatio) target--; else target++;
            $handle.animate({left: target},
                { 'duration' : duration,
                  'step' : function() {
                        lastRatio = Math.min(1.0, Math.max(0, $handle.offset().left / ($elem.width() - $handle.width())));
                        $elem.trigger('slider:pos', { ratio : lastRatio, handle : $handle[0] });
                    },
                  'complete' : function() { setTo(ratio); } // Last step gives us a % value again.
                }
            );
        }
        // Unfortunately, not even jQuery triggers resize events for our resizeable... so let's track the mouse.
        $el.on('mousedown', function() { $el.on('mousemove', fade); });
        $el.on('mouseup', function() { $el.off('mousemove', fade); fade(); });
    }
    /** Returns the current ratio. */
    function getValue() {
        return lastRatio;
    }
    $elem.append($handle);
    if (options.handleClass) {
        $handle.addClass(options.handleClass);
    } else { // Provide a default style so that it is at least visible
        $handle.css({ width: '10px', height: '10px', background: 'white', border: '1px solid black' });
    }
    if (options.initial) setTo(options.initial);
    /** Install mousedown handler to start mouse tracking. */
    $handle.on('mousedown', function(e) {
        $root.addClass('no-select');
        $doc.on('mousemove', track);
        $doc.on('mouseup', end);
    });
});
    return { setRatio: setTo, moveRatio: moveTo, getRatio: getValue, handle: $handle[0] };
}
function setup() {
    $('.imgdiff-container').each(function() {
        var $this = $(this);
        var $overlaySlider = $this.find('.imgdiff-ovr-slider').first();
        var $opacitySlider = $this.find('.imgdiff-opa-slider').first();
        var overlayAccess = rangeSlider($overlaySlider, {handleClass: 'imgdiff-ovr-handle'});
        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.
        $overlaySlider.on('slider:pos', function(e, data) {
            var pos = $(data.handle).offset().left;
            var imgLeft = $img.offset().left; // Global
            var imgW = $img.width() + $img.position().left; // From left edge of $div
            if (pos <= imgLeft) {
                $div.width(0);
            } else if (pos <= imgLeft + imgW) {
                $div.width(pos - imgLeft);
            } else if ($div.width() < imgW) {
                $div.width(imgW);
            }
        });
        $opacitySlider.on('slider:pos', function(e, data) {
            if ($div.width() <= 0) overlayAccess.moveRatio(1.0, 500); // Make old image visible in a nice way
            $img.css('opacity', 1.0 - data.ratio);
        });
    });
}
$(setup); // Run on jQuery's dom-ready
})(jQuery);
src/main/resources/gitblit.css
@@ -1438,107 +1438,134 @@
    color: #555;
}
/* Image diffs.
   Kudos to Lea Verou: http://lea.verou.me/2014/07/image-comparison-slider-with-pure-css/
   Slightly modified by Tom to allow moving the slider fully at the left edge of the images. */
div.imgdiff {
    margin: 5px 2px;
    position: relative;
    display: inline-block;
    line-height: 0;
    padding-left: 18px;
/* Image diffs. */
/* Set on body during mouse tracking. */
.no-select {
    -webkit-touch-callout:none;
    -webkit-user-select:none;
    -khtml-user-select:none;
    -moz-user-select:none;
    -ms-user-select:none;
    user-select:none;
}
/* Note: width defines the initial position of the slider. Would have liked to have it
   at 50% initially, but that fails on webkit, which refuses to go below the specified
   width. (min-width won't help.) This is known behavior of webkit, see
   https://codereview.chromium.org/239983004 and https://bugs.webkit.org/show_bug.cgi?id=72948
   There is a hack (setting width to 1px in :hover) to work around this, but that causes
   ugly screen flicker and makes for a dreadful UI. We're better off setting the slider
   to the far left initially. */
div.imgdiff-container {
    padding: 10px;
    background: #EEE;
}
div.imgdiff {
    margin: 10px 20px;
    position:relative;
    display: inline-block;
    /* Checkerboard background to reveal transparency. */
    background-color: white;
    background-image: linear-gradient(45deg, #DDD 25%, transparent 25%, transparent 75%, #DDD 75%, #DDD), linear-gradient(45deg, #DDD 25%, transparent 25%, transparent 75%, #DDD 75%, #DDD);
    background-size:16px 16px;
    background-position:0 0, 8px 8px;
}
div.imgdiff-left {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 18px;
    width: 0;
    max-width: 100%;
    overflow: hidden;
    resize: horizontal;
    /* Some border that should be visible on most images, combined of a dark color (red)
       and white in case the image was all red itself or used other colors that would make
       a thin red line hard to make out. */
    border-right: 1px solid red;
    box-shadow: 1px 0px 0px 0px white;
}
div.imgdiff-left:before {
img.imgdiff {
    user-select: none;
    border: 1px solid #0F0;
}
img.imgdiff-old {
    user-select: none;
    border: 1px solid #F00;
}
.imgdiff-opa-container {
    width: 200px;
    height: 4px;
    margin: 12px 35px;
    padding: 0;
    position: relative;
    border-left: 1px solid #888;
    border-right: 1px solid #888;
    background: linear-gradient(to bottom, #888, #EEE 50%, #888);
}
.imgdiff-opa-container:before {
    content: '';
    position: absolute;
    right: 0;
    left: -20px;
    top: -4px;
    width : 12px;
    height: 12px;
    background-image: radial-gradient(6px at 50% 50%, rgba(255, 255, 255, 255) 50%, rgba(255, 255, 255, 0) 6px);
}
.imgdiff-opa-container:after {
    content: '';
    position: absolute;
    right: -20px;
    top: -4px;
    width : 12px;
    height: 12px;
    background-image: radial-gradient(6px at 50% 50%, #888, #888 1px, transparent 6px);
}
.imgdiff-opa-slider {
    position:absolute;
    top : 0;
    left: -5px;
    bottom: 0;
    width: 13px;
    height: 13px;
    background: linear-gradient(-45deg, red 50%, transparent 0);
    background-clip: content-box;
    cursor: ew-resize;
    right: -5px;
    text-align: left;
}
img.imgdiff-left {
    margin-left: 18px; /* Compensate for padding on outer div. */
    user-select: none;
.imgdiff-opa-handle {
    width: 10px;
    height: 10px;
    position: absolute;
    top: -3px;
    background-image: radial-gradient(5px at 50% 50%, #444, #888, transparent 5px);
}
img.imagediff {
    user-select: none;
    /* Checkerboard background */
    background-color: white;
    background-image: linear-gradient(45deg, #DDD 25%, transparent 25%, transparent 75%, #DDD 75%, #DDD), linear-gradient(45deg, #DDD 25%, transparent 25%, transparent 75%, #DDD 75%, #DDD);
    background-size: 16px 16px;
    background-position: 0 0, 8px 8px;
}
.diff-img {
    margin: 2px;
}
div.imgdiff-slider {
.imgdiff-ovr-slider {
    display: inline-block;
    margin: 0;
    padding: 0;
    position: relative;
    margin: 0px 5px;
    width: 418px;
    height: 18px;
    background: linear-gradient(to right, #F00, #0F0);
    border: 1px solid #888;
    text-align: left;
}
div.imgdiff-slider-resizeable {
    position: absolute;
.imgdiff-ovr-handle {
    width : 2px;
    height: 100%;
    top: 0px;
    left: 0px;
    bottom: 0px;
    width: 18px;
    min-width: 18px;
    max-width: 100%;
    overflow: hidden;
    resize: horizontal;
    border-right: 1px solid #888;
    /* The "handle" */
    background-image: linear-gradient(to right, white, white);
    background-size: 18px 18px;
    background-position: top right;
    background-repeat: no-repeat;
    cursor: ew-resize;
    background: linear-gradient(to right, #444, #FFF);
}
/* Provides the *left* border of the "handle" */
div.imagediff-slider-left {
.imgdiff-ovr-handle:before {
    content: '';
    position: absolute;
    top: 0px;
    right: 0px;
    bottom: 0px;
    margin-right:18px;
    border-right: 1px solid #888;
    right: -4px;
    bottom: -5px;
    width : 10px;
    height: 10px;
    background-image: radial-gradient(5px at 50% 50%, #444, #888, transparent 5px);
}
.imgdiff-ovr-handle:after {
    content: '';
    position: absolute;
    right: -4px;
    top: -5px;
    width : 10px;
    height: 10px;
    /* border: 1px solid red; */
    background-image: radial-gradient(5px at 50% 50%, #444, #888, transparent 5px);
}
/* End image diffs */