# HG changeset patch # User Marcin Kuzminski # Date 2020-07-13 09:12:21 # Node ID 114e65cb8bb18a4951b64b2c5f0630210d5a83f6 # Parent c1776819817e8aafe017ca0df67ed36d0f260ff0 diffs: added diff navigation to improve UX when browisng the full context diffs. diff --git a/grunt_config.json b/grunt_config.json --- a/grunt_config.json +++ b/grunt_config.json @@ -51,9 +51,12 @@ "<%= dirs.js.src %>/plugins/jquery.pjax.js", "<%= dirs.js.src %>/plugins/jquery.dataTables.js", "<%= dirs.js.src %>/plugins/flavoured_checkbox.js", + "<%= dirs.js.src %>/plugins/within_viewport.js", "<%= dirs.js.src %>/plugins/jquery.auto-grow-input.js", "<%= dirs.js.src %>/plugins/jquery.autocomplete.js", "<%= dirs.js.src %>/plugins/jquery.debounce.js", + "<%= dirs.js.src %>/plugins/jquery.scrollstop.js", + "<%= dirs.js.src %>/plugins/jquery.within-viewport.js", "<%= dirs.js.node_modules %>/mark.js/dist/jquery.mark.min.js", "<%= dirs.js.src %>/plugins/jquery.timeago.js", "<%= dirs.js.src %>/plugins/jquery.timeago-extension.js", diff --git a/rhodecode/lib/codeblocks.py b/rhodecode/lib/codeblocks.py --- a/rhodecode/lib/codeblocks.py +++ b/rhodecode/lib/codeblocks.py @@ -540,10 +540,11 @@ class DiffSet(object): }) file_chunks = patch['chunks'][1:] - for hunk in file_chunks: + for i, hunk in enumerate(file_chunks, 1): hunkbit = self.parse_hunk(hunk, source_file, target_file) hunkbit.source_file_path = source_file_path hunkbit.target_file_path = target_file_path + hunkbit.index = i filediff.hunks.append(hunkbit) # Simulate hunk on OPS type line which doesn't really contain any diff diff --git a/rhodecode/lib/helpers.py b/rhodecode/lib/helpers.py --- a/rhodecode/lib/helpers.py +++ b/rhodecode/lib/helpers.py @@ -53,7 +53,7 @@ from pygments.lexers import ( get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype) from pyramid.threadlocal import get_current_request - +from tempita import looper from webhelpers2.html import literal, HTML, escape from webhelpers2.html._autolink import _auto_link_urls from webhelpers2.html.tools import ( diff --git a/rhodecode/public/css/code-block.less b/rhodecode/public/css/code-block.less --- a/rhodecode/public/css/code-block.less +++ b/rhodecode/public/css/code-block.less @@ -998,6 +998,21 @@ input.filediff-collapse-state { /**** END COMMENTS ****/ + + .nav-chunk { + position: absolute; + right: 20px; + margin-top: -17px; + } + + .nav-chunk.selected { + visibility: visible !important; + } + + #diff_nav { + color: @grey3; + } + } diff --git a/rhodecode/public/js/src/plugins/jquery.scrollstop.js b/rhodecode/public/js/src/plugins/jquery.scrollstop.js new file mode 100644 --- /dev/null +++ b/rhodecode/public/js/src/plugins/jquery.scrollstop.js @@ -0,0 +1,91 @@ +// jQuery Scrollstop Plugin v1.2.0 +// https://github.com/ssorallen/jquery-scrollstop + +(function (factory) { + // UMD[2] wrapper for jQuery plugins to work in AMD or in CommonJS. + // + // [2] https://github.com/umdjs/umd + + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node/CommonJS + module.exports = factory(require('jquery')); + } else { + // Browser globals + factory(jQuery); + } +}(function ($) { + // $.event.dispatch was undocumented and was deprecated in jQuery 1.7[1]. It + // was replaced by $.event.handle in jQuery 1.9. + // + // Use the first of the available functions to support jQuery <1.8. + // + // [1] https://github.com/jquery/jquery-migrate/blob/master/src/event.js#L25 + var dispatch = $.event.dispatch || $.event.handle; + + var special = $.event.special, + uid1 = 'D' + (+new Date()), + uid2 = 'D' + (+new Date() + 1); + + special.scrollstart = { + setup: function(data) { + var _data = $.extend({ + latency: special.scrollstop.latency + }, data); + + var timer, + handler = function(evt) { + var _self = this, + _args = arguments; + + if (timer) { + clearTimeout(timer); + } else { + evt.type = 'scrollstart'; + dispatch.apply(_self, _args); + } + + timer = setTimeout(function() { + timer = null; + }, _data.latency); + }; + + $(this).bind('scroll', handler).data(uid1, handler); + }, + teardown: function() { + $(this).unbind('scroll', $(this).data(uid1)); + } + }; + + special.scrollstop = { + latency: 250, + setup: function(data) { + var _data = $.extend({ + latency: special.scrollstop.latency + }, data); + + var timer, + handler = function(evt) { + var _self = this, + _args = arguments; + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(function() { + timer = null; + evt.type = 'scrollstop'; + dispatch.apply(_self, _args); + }, _data.latency); + }; + + $(this).bind('scroll', handler).data(uid2, handler); + }, + teardown: function() { + $(this).unbind('scroll', $(this).data(uid2)); + } + }; +})); diff --git a/rhodecode/public/js/src/plugins/jquery.within-viewport.js b/rhodecode/public/js/src/plugins/jquery.within-viewport.js new file mode 100644 --- /dev/null +++ b/rhodecode/public/js/src/plugins/jquery.within-viewport.js @@ -0,0 +1,171 @@ +/** + * Within Viewport jQuery Plugin + * + * @description Companion plugin for withinviewport.js - determines whether an element is completely within the browser viewport + * @author Craig Patik, http://patik.com/ + * @version 2.1.2 + * @date 2019-08-16 + */ +(function ($) { + /** + * $.withinviewport() + * @description jQuery method + * @param {Object} [settings] optional settings + * @return {Collection} Contains all elements that were within the viewport + */ + $.fn.withinviewport = function (settings) { + var opts; + var elems; + + if (typeof settings === 'string') { + settings = { + sides: settings + }; + } + + opts = $.extend({}, settings, { + sides: 'all' + }); + elems = []; + + this.each(function () { + if (withinviewport(this, opts)) { + elems.push(this); + } + }); + + return $(elems); + }; + + // Main custom selector + $.extend($.expr[':'], { + 'within-viewport': function (element) { + return withinviewport(element, 'all'); + } + }); + + /** + * Optional enhancements and shortcuts + * + * @description Uncomment or comment these pieces as they apply to your project and coding preferences + */ + + // Shorthand jQuery methods + + $.fn.withinviewporttop = function (settings) { + var opts; + var elems; + + if (typeof settings === 'string') { + settings = { + sides: settings + }; + } + + opts = $.extend({}, settings, { + sides: 'top' + }); + elems = []; + + this.each(function () { + if (withinviewport(this, opts)) { + elems.push(this); + } + }); + + return $(elems); + }; + + $.fn.withinviewportright = function (settings) { + var opts; + var elems; + + if (typeof settings === 'string') { + settings = { + sides: settings + }; + } + + opts = $.extend({}, settings, { + sides: 'right' + }); + elems = []; + + this.each(function () { + if (withinviewport(this, opts)) { + elems.push(this); + } + }); + + return $(elems); + }; + + $.fn.withinviewportbottom = function (settings) { + var opts; + var elems; + + if (typeof settings === 'string') { + settings = { + sides: settings + }; + } + + opts = $.extend({}, settings, { + sides: 'bottom' + }); + elems = []; + + this.each(function () { + if (withinviewport(this, opts)) { + elems.push(this); + } + }); + + return $(elems); + }; + + $.fn.withinviewportleft = function (settings) { + var opts; + var elems; + + if (typeof settings === 'string') { + settings = { + sides: settings + }; + } + + opts = $.extend({}, settings, { + sides: 'left' + }); + elems = []; + + this.each(function () { + if (withinviewport(this, opts)) { + elems.push(this); + } + }); + + return $(elems); + }; + + // Custom jQuery selectors + $.extend($.expr[':'], { + 'within-viewport-top': function (element) { + return withinviewport(element, 'top'); + }, + 'within-viewport-right': function (element) { + return withinviewport(element, 'right'); + }, + 'within-viewport-bottom': function (element) { + return withinviewport(element, 'bottom'); + }, + 'within-viewport-left': function (element) { + return withinviewport(element, 'left'); + } + // Example custom selector: + //, + // 'within-viewport-top-left-45': function (element) { + // return withinviewport(element, {sides:'top left', top: 45, left: 45}); + // } + }); +}(jQuery)); \ No newline at end of file diff --git a/rhodecode/public/js/src/plugins/within_viewport.js b/rhodecode/public/js/src/plugins/within_viewport.js new file mode 100644 --- /dev/null +++ b/rhodecode/public/js/src/plugins/within_viewport.js @@ -0,0 +1,235 @@ +/** + * Within Viewport + * + * @description Determines whether an element is completely within the browser viewport + * @author Craig Patik, http://patik.com/ + * @version 2.1.2 + * @date 2019-08-16 + */ +(function (root, name, factory) { + // AMD + if (typeof define === 'function' && define.amd) { + define([], factory); + } + // Node and CommonJS-like environments + else if (typeof module !== 'undefined' && typeof exports === 'object') { + module.exports = factory(); + } + // Browser global + else { + root[name] = factory(); + } +}(this, 'withinviewport', function () { + var canUseWindowDimensions = typeof window !== 'undefined' && window.innerHeight !== undefined; // IE 8 and lower fail this + + /** + * Determines whether an element is within the viewport + * @param {Object} elem DOM Element (required) + * @param {Object} options Optional settings + * @return {Boolean} Whether the element was completely within the viewport + */ + var withinviewport = function withinviewport(elem, options) { + var result = false; + var metadata = {}; + var config = {}; + var settings; + var isWithin; + var isContainerTheWindow; + var elemBoundingRect; + var containerBoundingRect; + var containerScrollTop; + var containerScrollLeft; + var scrollBarWidths = [0, 0]; + var sideNamesPattern; + var sides; + var side; + var i; + + // If invoked by the jQuery plugin, get the actual DOM element + if (typeof jQuery !== 'undefined' && elem instanceof jQuery) { + elem = elem.get(0); + } + + if (typeof elem !== 'object' || elem.nodeType !== 1) { + throw new Error('First argument must be an element'); + } + + // Look for inline settings on the element + if (elem.getAttribute('data-withinviewport-settings') && window.JSON) { + metadata = JSON.parse(elem.getAttribute('data-withinviewport-settings')); + } + + // Settings argument may be a simple string (`top`, `right`, etc) + if (typeof options === 'string') { + settings = { + sides: options + }; + } else { + settings = options || {}; + } + + // Build configuration from defaults and user-provided settings and metadata + config.container = settings.container || metadata.container || withinviewport.defaults.container || window; + config.sides = settings.sides || metadata.sides || withinviewport.defaults.sides || 'all'; + config.top = settings.top || metadata.top || withinviewport.defaults.top || 0; + config.right = settings.right || metadata.right || withinviewport.defaults.right || 0; + config.bottom = settings.bottom || metadata.bottom || withinviewport.defaults.bottom || 0; + config.left = settings.left || metadata.left || withinviewport.defaults.left || 0; + + // Extract the DOM node from a jQuery collection + if (typeof jQuery !== 'undefined' && config.container instanceof jQuery) { + config.container = config.container.get(0); + } + + // Use the window as the container if the user specified the body or a non-element + if (config.container === document.body || config.container.nodeType !== 1) { + config.container = window; + } + + isContainerTheWindow = (config.container === window); + + // Element testing methods + isWithin = { + // Element is below the top edge of the viewport + top: function _isWithin_top() { + if (isContainerTheWindow) { + return (elemBoundingRect.top >= config.top); + } else { + return (elemBoundingRect.top >= containerScrollTop - (containerScrollTop - containerBoundingRect.top) + config.top); + } + }, + + // Element is to the left of the right edge of the viewport + right: function _isWithin_right() { + // Note that `elemBoundingRect.right` is the distance from the *left* of the viewport to the element's far right edge + + if (isContainerTheWindow) { + return (elemBoundingRect.right <= (containerBoundingRect.right + containerScrollLeft) - config.right); + } else { + return (elemBoundingRect.right <= containerBoundingRect.right - scrollBarWidths[0] - config.right); + } + }, + + // Element is above the bottom edge of the viewport + bottom: function _isWithin_bottom() { + var containerHeight = 0; + + if (isContainerTheWindow) { + if (canUseWindowDimensions) { + containerHeight = config.container.innerHeight; + } else if (document && document.documentElement) { + containerHeight = document.documentElement.clientHeight; + } + } else { + containerHeight = containerBoundingRect.bottom; + } + + // Note that `elemBoundingRect.bottom` is the distance from the *top* of the viewport to the element's bottom edge + return (elemBoundingRect.bottom <= containerHeight - scrollBarWidths[1] - config.bottom); + }, + + // Element is to the right of the left edge of the viewport + left: function _isWithin_left() { + if (isContainerTheWindow) { + return (elemBoundingRect.left >= config.left); + } else { + return (elemBoundingRect.left >= containerScrollLeft - (containerScrollLeft - containerBoundingRect.left) + config.left); + } + }, + + // Element is within all four boundaries + all: function _isWithin_all() { + // Test each boundary in order of efficiency and likeliness to be false. This way we can avoid running all four functions on most elements. + // 1. Top: Quickest to calculate + most likely to be false + // 2. Bottom: Note quite as quick to calculate, but also very likely to be false + // 3-4. Left and right are both equally unlikely to be false since most sites only scroll vertically, but left is faster to calculate + return (isWithin.top() && isWithin.bottom() && isWithin.left() && isWithin.right()); + } + }; + + // Get the element's bounding rectangle with respect to the viewport + elemBoundingRect = elem.getBoundingClientRect(); + + // Get viewport dimensions and offsets + if (isContainerTheWindow) { + containerBoundingRect = document.documentElement.getBoundingClientRect(); + containerScrollTop = document.body.scrollTop; + containerScrollLeft = window.scrollX || document.body.scrollLeft; + } else { + containerBoundingRect = config.container.getBoundingClientRect(); + containerScrollTop = config.container.scrollTop; + containerScrollLeft = config.container.scrollLeft; + } + + // Don't count the space consumed by scrollbars + if (containerScrollLeft) { + scrollBarWidths[0] = 18; + } + + if (containerScrollTop) { + scrollBarWidths[1] = 16; + } + + // Test the element against each side of the viewport that was requested + sideNamesPattern = /^top$|^right$|^bottom$|^left$|^all$/; + + // Loop through all of the sides + sides = config.sides.split(' '); + i = sides.length; + + while (i--) { + side = sides[i].toLowerCase(); + + if (sideNamesPattern.test(side)) { + if (isWithin[side]()) { + result = true; + } else { + result = false; + + // Quit as soon as the first failure is found + break; + } + } + } + + return result; + }; + + // Default settings + withinviewport.prototype.defaults = { + container: typeof document !== 'undefined' ? document.body : {}, + sides: 'all', + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + + withinviewport.defaults = withinviewport.prototype.defaults; + + /** + * Optional enhancements and shortcuts + * + * @description Uncomment or comment these pieces as they apply to your project and coding preferences + */ + + // Shortcut methods for each side of the viewport + // Example: `withinviewport.top(elem)` is the same as `withinviewport(elem, 'top')` + withinviewport.prototype.top = function _withinviewport_top(element) { + return withinviewport(element, 'top'); + }; + + withinviewport.prototype.right = function _withinviewport_right(element) { + return withinviewport(element, 'right'); + }; + + withinviewport.prototype.bottom = function _withinviewport_bottom(element) { + return withinviewport(element, 'bottom'); + }; + + withinviewport.prototype.left = function _withinviewport_left(element) { + return withinviewport(element, 'left'); + }; + + return withinviewport; +})); \ No newline at end of file diff --git a/rhodecode/templates/codeblocks/diffs.mako b/rhodecode/templates/codeblocks/diffs.mako --- a/rhodecode/templates/codeblocks/diffs.mako +++ b/rhodecode/templates/codeblocks/diffs.mako @@ -314,6 +314,7 @@ return '%s_%s_%i' % (h.md5_safe(commit+f ${hunk.section_header} + ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], hunk, use_comments=use_comments, inline_comments=inline_comments, active_pattern_entries=active_pattern_entries)} % endfor @@ -657,21 +658,28 @@ def get_comments_for(diff_type, comments %> <%def name="render_hunk_lines_sideside(filediff, hunk, use_comments=False, inline_comments=None, active_pattern_entries=None)"> - %for i, line in enumerate(hunk.sideside): + + <% chunk_count = 1 %> + %for loop_obj, item in h.looper(hunk.sideside): <% + line = item + i = loop_obj.index + prev_line = loop_obj.previous old_line_anchor, new_line_anchor = None, None if line.original.lineno: old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, line.original.lineno, 'o') if line.modified.lineno: new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, line.modified.lineno, 'n') + + line_action = line.modified.action or line.original.action + prev_line_action = prev_line and (prev_line.modified.action or prev_line.original.action) %> -
<% line_old_comments = None %> %if line.original.get_comment_args: @@ -685,7 +693,6 @@ def get_comments_for(diff_type, comments % endif %endif -
+ + + <% chunk_count +=1 %> + % endif %endfor @@ -903,12 +916,21 @@ def get_comments_for(diff_type, comments -
+
Context file:
+
+ Loading diff...: + + + + + + +
@@ -1031,10 +1053,84 @@ def get_comments_for(diff_type, comments e.preventDefault(); }); + getCurrentChunk = function () { + + var chunksAll = $('.nav-chunk').filter(function () { + return $(this).parents('.filediff').prev().get(0).checked !== true + }) + var chunkSelected = $('.nav-chunk.selected'); + var initial = false; + + if (chunkSelected.length === 0) { + // no initial chunk selected, we pick first + chunkSelected = $(chunksAll.get(0)); + var initial = true; + } + + return { + 'all': chunksAll, + 'selected': chunkSelected, + 'initial': initial, + } + } + + animateDiffNavText = function () { + var $diffNav = $('#diff_nav') + + var callback = function () { + $diffNav.animate({'opacity': 1.00}, 200) + }; + $diffNav.animate({'opacity': 0.15}, 200, callback); + } + + scrollToChunk = function (moveBy) { + var chunk = getCurrentChunk(); + var all = chunk.all + var selected = chunk.selected + + var curPos = all.index(selected); + var newPos = curPos; + if (!chunk.initial) { + var newPos = curPos + moveBy; + } + + var curElem = all.get(newPos); + + if (curElem === undefined) { + // end or back + $('#diff_nav').html('No next diff element.') + animateDiffNavText() + return + } else if (newPos < 0) { + $('#diff_nav').html('No previous diff element.') + animateDiffNavText() + return + } else { + $('#diff_nav').html('Diff navigation:') + } + + curElem = $(curElem) + var offset = 100; + $(window).scrollTop(curElem.position().top - offset); + + //clear selection + all.removeClass('selected') + curElem.addClass('selected') + } + + scrollToPrevChunk = function () { + scrollToChunk(-1) + } + scrollToNextChunk = function () { + scrollToChunk(1) + } + % endif