6
\$\begingroup\$

On a responsive website where elements may shrink/expand according to the size of your viewport, the elements that was at the top of your viewport at the time may get pushed down/up as the elements above them get squished/expanded. Forcing you to scroll down/up again in order to get to the same area you were looking at.

This javascript code attempts to get rid of this problem by finding and updating the element at/near the top of the viewport on load and on scroll. Then it performs a scroll towards the current top element when the size of the viewport changes.

Based heavily on the answer I've received in my SO question for this, I got the following code in the end:

// Define detection area (where to pick element that we will lock on to)
var DETECT_TOP    =  5,
    DETECT_SIDE   = 60,
    DETECT_HEIGHT = 60;

// Step between each check in the detection box
// laggy if too small
var DETECT_STEP_X = 50,
    DETECT_STEP_Y = 10;

// Cut-off is when the element we detected is sliced
// in-between by the top of the viewport
var CUTOFF_ADJUST = true;

var viewportWidth = $(window).width();


function Node () {
    var that = this;
    this.element = null;

    // data
    this.documentOffset = {
        // jQuery element.offset().top is funny in old Safari
        top: function () { 
            if (!that.element) { return 0; }
            return that.element.offsetTop + that.element.offsetParent.offsetTop; 
        },
        bottom: function () { 
            if (!that.element) { return 0; }
            return this.top() + $(that.element).height(); 
        }
    };
    this.viewportOffset = {
        //offsets before and after resizing may be different in a responsive website 
        // (thinner <p> increases its height due to word-wrap, etc.)
        oldTop:    0,
        oldBottom: 0,

        top: function () { 
            this.oldTop = that.documentOffset.top() - $(window).scrollTop();
            return this.oldTop;
        },
        bottom: function () { 
            this.oldBottom = that.documentOffset.bottom() - $(window).scrollTop();
            return this.oldBottom;
        }
    };

    // Main functions
    this.update = function () {
        var sampleElement, timer;
        clearTimeout(timer);
        //
        // Timeout stuff = fix for old IE 
        // https://stackoverflow.com/questions/8227345/elementfrompoint-returns-null-when-it-used-in-onscroll-event
        //
        timer = setTimeout(
            function () {

                for (var y = DETECT_TOP; y <= DETECT_TOP + DETECT_HEIGHT; y+= DETECT_STEP_Y) {
                    for (var x = DETECT_SIDE; x <= $(document).width() - DETECT_SIDE; x+= DETECT_STEP_X) {
                        //scans the detected box for an element
                        sampleElement = getElementAt(x, y);


                        // Change element criterions here
                        // e.g. we don't want it to pick underlying container div
                        //      don't pick fixed sidebar, etc. etc.
                        if (sampleElement && sampleElement.children.length === 0) {


                            that.element = sampleElement;
                            that.updateOffsets();
                            return;
                        }
                    }
                }

            },
            0
        );
    };
    this.scrollTo = function (act) {
        var scrollAmount = that.documentOffset.top();

        //calculate scroll amount, then perform it
        if (that.element) {
            if (CUTOFF_ADJUST) {
                if (that.wasBelowViewportTop()) {
                    scrollAmount -= that.viewportOffset.oldTop;
                } else {
                    scrollAmount = that.documentOffset.bottom() - that.viewportOffset.oldBottom;
                }
            }

            if (navigator.userAgent.indexOf('Safari') != -1 && navigator.userAgent.indexOf('Chrome') == -1) {
                //
                // Setting scrollTop() on Safari:
                //   $(document).scrollTop(scrollAmount)  works only when expanding
                //   $("body").scrollTop(scrollAmount)    works only when shrinking
                //
                if (act == "expanding") {
                    $(document).scrollTop(scrollAmount);
                }
                if (act == "shrinking") {
                    $("body").scrollTop(scrollAmount);
                }
            } else {
                $("html, body").scrollTop(scrollAmount);
            }

        that.updateOffsets();
        }
    };

    // helper functions
    this.updateOffsets = function () {
        that.viewportOffset.top();
        that.viewportOffset.bottom();
    };
    this.wasBelowViewportTop = function () {
        if (this.viewportOffset.oldTop >= 0) {
            return true;
        }
        return false;
    };
}

var elementFromPointFeatureCheck = false,
    elementFromPointIsRelative   = true;
function getElementAt (x, y) {
    //
    // Various fixes for document.elementFromPoint(x, y)
    //  null check is for IE8,
    // !elementFromPointIsRelative is for browsers that uses x, y relative to document (some old Safari and Opera etc.)
    //  elementFromPointIsRelative is for browsers that uses x, y relative to viewport (default functionality)
    //
    // https://stackoverflow.com/questions/17953672/document-elementfrompoint-in-jquery/19363125#19363125
    // http://www.zehnet.de/2010/11/19/document-elementfrompoint-a-jquery-solution/
    //  (see comment on zehnet)
    //
    var scroll, doc;
    if (!document.elementFromPoint) {
        return null;
    }
    if (!elementFromPointFeatureCheck) {
        if ((scroll = $(window).scrollTop()) > 0) {
            doc = document.elementFromPoint(0, scroll + $(window).height() -1);
            if (doc !== null && doc.tagName == "HTML") {
                doc = null;
            }
            elementFromPointIsRelative = (doc === null);
        }
        elementFromPointFeatureCheck = (scroll > 0);
    }
    if (!elementFromPointIsRelative) {
        y += $(window).scrollTop();
    } 
    return document.elementFromPoint(x, y);
}

That's the main definitions chunk of the code. Then I put it all together:

var detectedNode = new Node();
$(window)
    .load(detectedNode.update)
    .scroll(detectedNode.update)
    .resize(function () {
        var act = (viewportWidth <= $(window).width()) ? "expanding" : "shrinking";
        viewportWidth = $(window).width();
        detectedNode.scrollTo(act);
    });

And everything is inside a jQuery(document).ready().

What are some changes I should make and is there any issues and/or best practices that I should be made aware of?
I think that the code is a little too cluttered with browser fixes, but I am not too sure how, or whether or not I should separate them to make the main part of the code more readable.
I am also rather new to oop, but I have a feeling that my Node object is doing more than it should. Although we scrollTo the node, but it isn't the node that's doing the scrolling? But then what about the node update method?


Demo

Blank mark-up for comparison.
jsbin Demo with the mark-up above.


Browser Support

It should at least work in recent versions of chrome, ff, ie, safari, and opera.
I am still trying to add fixes to make it work for slightly older versions like saf 4, opera 10 and/or beyond, but these aren't a priority.
Here's the quirksmode page containing the functions used in the code.

\$\endgroup\$
6
\$\begingroup\$

I just saw this

this.viewportOffset = {
    //offsets before and after resizing may be different in a responsive website 
    // (thinner <p> increases its height due to word-wrap, etc.)
    oldTop:    0,
    oldBottom: 0,

    top: function () { 
        this.oldTop = that.documentOffset.top() - $(window).scrollTop();
        return this.oldTop;
    },
    bottom: function () { 
        this.oldBottom = that.documentOffset.bottom() - $(window).scrollTop();
        return this.oldBottom;
    }
};

I think that you can remove this.oldTop and this.oldBottom by writing it like this

this.viewportOffset = {
    //offsets before and after resizing may be different in a responsive website 
    // (thinner <p> increases its height due to word-wrap, etc.)
    top: function () { 
        return that.documentOffset.top() - $(window).scrollTop();
    },
    bottom: function () { 
        return that.documentOffset.bottom() - $(window).scrollTop();
    }
};

Less variables being declared means less memory being taken up by the website equals a faster, more responsive website.


This could be a little shorter and more to the point

this.wasBelowViewportTop = function () {
    if (this.viewportOffset.oldTop >= 0) {
        return true;
    }
    return false;
};

By using a ternary expression

this.wasBelowViewportTop = function () { this.viewportOffset.oldTop >= 0 ? true : false; };

But this is Silly as @Konijn pointed out just set it

this.wasBelowViewportTop = function () { this.viewportOffset.oldTop >= 0;};

It will evaluate the conditional and set this.wasBelowViewportTop to a true/false value.


Edit

Where you use this function can also be cleaned up by using a ternary statement

if (that.wasBelowViewportTop()) {
    scrollAmount -= that.viewportOffset.oldTop;
} else {
    scrollAmount = that.documentOffset.bottom() - that.viewportOffset.oldBottom;
}

Would become this (one line statement spread across lines for viewing)

scrollAmount = that.wasBelowViewportTop() 
    ? scrollAmount - that.viewportOffset.oldTop 
    : that.documentOffset.bottom() - that.viewportOffset.oldBottom;

Another thing that you could do is to get rid of the function altogether, you are only using it in this one place, so you could write the ternary statement like this instead

scrollAmount = this.viewportOffset.oldTop >= 0 
    ? scrollAmount - that.viewportOffset.oldTop 
    : that.documentOffset.bottom() - that.viewportOffset.oldBottom;

This lowers the code count quite a bit.

\$\endgroup\$
  • 1
    \$\begingroup\$ Also a ternary for true/false does not make sense you would do return this.viewportOffset.oldTop >= 0; \$\endgroup\$ – konijn Aug 13 '14 at 20:52
  • \$\begingroup\$ You are mapping true/false with a ternary to true/false, instead of just using the evaluation of the condition. Do you see why that does not make sense ( or at least, is overkill ). \$\endgroup\$ – konijn Aug 13 '14 at 20:57
  • \$\begingroup\$ Ok, I'll make these changes in the code above. @top/bottom bit, in the scrolling function, the scroll amount is determined by the new documentOffset (as it may get pushed up or down) after window resize. But this aligns the top of the element to the top of the viewport, so I adjusted the scroll amount by the old viewportOffset before the window resize which is being kept updated with vpOffset.top() during scroll or load event, but when accessing it on resize, using top() again will get the new vpOffset? I also gather that my names are not very good, I'll attend to this as well. \$\endgroup\$ – Sylin Aug 14 '14 at 1:44
  • \$\begingroup\$ If I only used wasBelowViewportTop once, is it even better still to use the expression in the if condition since it's a method that is unlikely to change? I've also tested out removing oldTop and oldBottom, but it breaks the code. Is there a pattern that implements what I've been describing in a better way? \$\endgroup\$ – Sylin Aug 14 '14 at 2:42
  • \$\begingroup\$ I don't see why it wouldn't work... \$\endgroup\$ – Malachi Oct 7 '14 at 20:50
2
\$\begingroup\$

Ok, here's my first revision :)

  1. Simplifying viewportOffset.top / bottom from Malachi

    this.viewport = {
        top:    0,
        bottom: 0,
    
        update: function () {
            this.top    = that.documentOffset.top()    - $(window).scrollTop();
            this.bottom = that.documentOffset.bottom() - $(window).scrollTop();
        }
    }
    

    Since the scrolling needs to be adjusted by the viewport offset of the element before resizing, top and bottom is kept as a property so we can access them without updating it, and then we call the update method just on load and on scroll.

  2. I then use the same pattern when checking viewport width to detect expanding/scrolling:

    $(window).resize(function () {
        viewport.isExpanding = (viewport.width <= $(window).width());
        viewport.width = $(window).width();
    
        viewport.scrollTo(detectedNode);
    });
    

    But this uses a viewport object.

  3. So I add it and use the chance to reorganise some functions around.

    var detectedNode = new Node();
    var viewport     = new Viewport();
    
    function Node () {
        this.element;
        this.documentOffset = { .. };
        this.viewportOffset = { .. };
        this.update = function () { .. };
    }
    function Viewport () {
        this.width;
        this.isExpanding; //bool
        this.scrollTo = function (node) { .. };
        this.elementFromPoint (x, y) { .. };
    }
    
  4. Fixed some names like elementFromPointIsRelative and elementFromPointFeatureCheck as suggested in the comments.


1st Revision JSbin

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.