Skip to content

Instantly share code, notes, and snippets.

@nathancahill
Last active November 1, 2016 03:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nathancahill/fa0bf3b6ff0ed98f58604a6ef379e269 to your computer and use it in GitHub Desktop.
Save nathancahill/fa0bf3b6ff0ed98f58604a6ef379e269 to your computer and use it in GitHub Desktop.

This is a Markdown representation of a small library that I wrote called Split.js. The ~500 lines of source code is here: https://github.com/nathancahill/Split.js/blob/master/split.js. While the library was an experiment in manually optimizing for file size, this document is an experiment in readable commentary alongside the code. Feel free to leave comments/submit PRs with feedback.


The programming goals of Split.js are to deliver readable, understandable and maintainable code, while at the same time manually optimizing for tiny minified file size, browser compatibility without additional requirements, graceful fallback (IE8 is supported) and very few assumptions about the user's page layout.

Make sure all browsers handle this JS library correctly with ES5. More information here: Strict mode - JavaScript | MDN

'use strict';

A wrapper function that does a couple things:

  1. Doesn't pollute the global namespace. This is important for a library.
  2. Allows us to mount the library in different module systems, as well as directly in the browser.
(function() {

Save the global this for use later. In this case, since the library only runs in the browser, it will refer to window. Also, figure out if we're in IE8 or not. IE8 will still render correctly, but will be static instead of draggable.

Save a couple long function names that are used frequently. This optimization saves around 400 bytes.

var global = this
  , isIE8 = global.attachEvent && !global[addEventListener]
  , document = global.document
  , addEventListener = 'addEventListener'
  , removeEventListener = 'removeEventListener'
  , getBoundingClientRect = 'getBoundingClientRect'

This library only needs two helper functions:

The first determines which prefixes of CSS calc we need. We only need to do this once on startup, when this anonymous function is called.

Tests -webkit, -moz and -o prefixes. Modified from: JS feature detection to detect the usage of -webkit-calc over calc - Stack Overflow

  , calc = (function () {
        var el
          , prefixes = ["", "-webkit-", "-moz-", "-o-"]

        for (var i = 0; i < prefixes.length; i++) {
            el = document.createElement('div')
            el.style.cssText = "width:" + prefixes[i] + "calc(9px)"

            if (el.style.length) {
                return prefixes[i] + "calc"
            }
        }
    })()

The second helper function allows elements and string selectors to be used interchangeably. In either case an element is returned. This allows us to do Split(elem1, elem2) as well as Split('#id1', '#id2').

  , elementOrSelector = function (el) {
        if (typeof el === 'string' || el instanceof String) {
            return document.querySelector(el)
        } else {
            return el
        }
    }

The main function to initialize a split. Split.js thinks about each pair of elements as an independant pair. Dragging the gutter between two elements only changes the dimensions of elements in that pair. This is key to understanding how the following functions operate, since each function is bound to a pair.

A pair object is shaped like this:

{
  a: DOM element,
  b: DOM element,
  aMin: Number,
  bMin: Number,
  dragging: Boolean,
  parent: DOM element,
  isFirst: Boolean,
  isLast: Boolean,
  direction: 'horizontal' | 'vertical'
}

The basic sequence:

  1. Set defaults to something sane. options doesn't have to be passed at all.
  2. Initialize a bunch of strings based on the direction we're splitting. A lot of the behavior in the rest of the library is paramatized down to rely on CSS strings and classes.
  3. Define the dragging helper functions, and a few helpers to go with them.
  4. Define a few more functions that "balance" the entire split instance. Split.js tries it's best to cope with min sizes that don't add up.
  5. Loop through the elements while pairing them off. Every pair gets an pair object, a gutter, and special isFirst/isLast properties.
  6. Actually size the pair elements, insert gutters and attach event listeners.
  7. Balance all of the pairs to accomodate min sizes as best as possible.
  , Split = function (ids, options) {
    var dimension
      , i
      , clientDimension
      , clientAxis
      , position
      , gutterClass
      , paddingA
      , paddingB
      , pairs = []

1. Set defaults to something sane

options doesn't have to be passed at all, so create an options object if none exists. Pixel values 10, 100 and 30 are arbitrary but feel natural.

    options = typeof options !== 'undefined' ?  options : {}

    if (typeof options.gutterSize === 'undefined') options.gutterSize = 10
    if (typeof options.minSize === 'undefined') options.minSize = 100
    if (typeof options.snapOffset === 'undefined') options.snapOffset = 30
    if (typeof options.direction === 'undefined') options.direction = 'horizontal'

2. Initialize a bunch of strings

Based on the direction we're splitting. A lot of the behavior in the rest of the library is paramatized down to rely on CSS strings and classes.

    if (options.direction == 'horizontal') {
        dimension = 'width'
        clientDimension = 'clientWidth'
        clientAxis = 'clientX'
        position = 'left'
        gutterClass = 'gutter gutter-horizontal'
        paddingA = 'paddingLeft'
        paddingB = 'paddingRight'
        if (!options.cursor) options.cursor = 'ew-resize'
    } else if (options.direction == 'vertical') {
        dimension = 'height'
        clientDimension = 'clientHeight'
        clientAxis = 'clientY'
        position = 'top'
        gutterClass = 'gutter gutter-vertical'
        paddingA = 'paddingTop'
        paddingB = 'paddingBottom'
        if (!options.cursor) options.cursor = 'ns-resize'
    }

3. Define the dragging helper functions

And a few helpers to go with them. Each helper is bound to a pair object that contains it's metadata. This also makes it easy to store references to listeners that that will be added and removed.

Even though there are no other functions contained in them, aliasing this to self saves 50 bytes or so since it's used so frequently.

The pair object saves metadata like dragging state, position and event listener references.

startDragging calls calculateSizes to store the inital size in the pair object. It also adds event listeners for mouse/touch events, and prevents selection while dragging so avoid the selecting text.

    var startDragging = function (e) {

Alias frequently used variables to save space. 200 bytes.

            var self = this
              , a = self.a
              , b = self.b

Call the onDragStart callback.

            if (!self.dragging && options.onDragStart) {
                options.onDragStart()
            }

Don't actually drag the element. We emulate that in the drag function.

            e.preventDefault()

Set the dragging property of the pair object.

            self.dragging = true

Create two event listeners bound to the same pair object and store them in the pair object.

            self.move = drag.bind(self)
            self.stop = stopDragging.bind(self)

All the binding. window gets the stop events in case we drag out of the elements.

            global[addEventListener]('mouseup', self.stop)
            global[addEventListener]('touchend', self.stop)
            global[addEventListener]('touchcancel', self.stop)

            self.parent[addEventListener]('mousemove', self.move)
            self.parent[addEventListener]('touchmove', self.move)

Disable selection. Disable!

            a[addEventListener]('selectstart', noop)
            a[addEventListener]('dragstart', noop)
            b[addEventListener]('selectstart', noop)
            b[addEventListener]('dragstart', noop)

            a.style.userSelect = 'none'
            a.style.webkitUserSelect = 'none'
            a.style.MozUserSelect = 'none'
            a.style.pointerEvents = 'none'

            b.style.userSelect = 'none'
            b.style.webkitUserSelect = 'none'
            b.style.MozUserSelect = 'none'
            b.style.pointerEvents = 'none'

Set the cursor, both on the gutter and the parent element. Doing only a, b and gutter causes flickering.

            self.gutter.style.cursor = options.cursor
            self.parent.style.cursor = options.cursor

Cache the initial sizes of the pair.

            calculateSizes.call(self)
        }

stopDragging is very similar to startDragging in reverse.

      , stopDragging = function () {
            var self = this
              , a = self.a
              , b = self.b

            if (self.dragging && options.onDragEnd) {
                options.onDragEnd()
            }

            self.dragging = false

Remove the stored event listeners. This is why we store them.

            global[removeEventListener]('mouseup', self.stop)
            global[removeEventListener]('touchend', self.stop)
            global[removeEventListener]('touchcancel', self.stop)

            self.parent[removeEventListener]('mousemove', self.move)
            self.parent[removeEventListener]('touchmove', self.move)

Delete them once they are removed. I think this makes a difference in memory usage with a lot of splits on one page. But I don't know for sure.

            delete self.stop
            delete self.move

            a[removeEventListener]('selectstart', noop)
            a[removeEventListener]('dragstart', noop)
            b[removeEventListener]('selectstart', noop)
            b[removeEventListener]('dragstart', noop)

            a.style.userSelect = ''
            a.style.webkitUserSelect = ''
            a.style.MozUserSelect = ''
            a.style.pointerEvents = ''

            b.style.userSelect = ''
            b.style.webkitUserSelect = ''
            b.style.MozUserSelect = ''
            b.style.pointerEvents = ''

            self.gutter.style.cursor = ''
            self.parent.style.cursor = ''
        }

drag, where all the magic happens. The logic is really quite simple:

  1. Ignore if the pair is not dragging.
  2. Get the offset of the event.
  3. Snap offset to min if within snappable range (within min + snapOffset).
  4. Actually adjust each element in the pair to offset.
---------------------------------------------------------------------
|    | <- aMin                    ||                   bMin -> |    |
|    |  | <- snapOffset           ||          snapOffset -> |  |    |
|    |  |                         ||                        |  |    |
|    |  |                         ||                        |  |    |
---------------------------------------------------------------------
| <- start                                                  size -> |
      , drag = function (e) {
            var offset

            if (!this.dragging) return

Get the offset of the event from the first side of the pair this.start. Supports touch events, but not multitouch, so only the finger touches[0] is counted.

            if ('touches' in e) {
                offset = e.touches[0][clientAxis] - this.start
            } else {
                offset = e[clientAxis] - this.start
            }

If within snapOffset of min or max, set offset to min or max. snapOffset buffers aMin and bMin, so logic is opposite for both.

            if (offset <=  this.aMin + options.snapOffset) {
                offset = this.aMin
            } else if (offset >= this.size - (this.bMin + options.snapOffset)) {
                offset = this.size - this.bMin
            }

Actually adjust the size.

            adjust.call(this, offset)

Call the drag callback continously. Don't do anything too intensive in this callback.

            if (options.onDrag) {
                options.onDrag()
            }
        }

Cache some important sizes when drag starts, so we don't have to do that continously:

  • size: The total size of the pair. First element + second element + first gutter + second gutter.
  • percentage: The percentage between 0-100 that the pair occupies in the parent.
  • start: The leading side of the first element.
------------------------------------------------ - - - - - - - - - - -
|      aGutterSize -> |||                      |                     |
|                     |||                      |                     |
|                     |||                      |                     |
|                     ||| <- bGutterSize       |                     |
------------------------------------------------ - - - - - - - - - - -
| <- start                             size -> |       parentSize -> |

Figure out the parent size minus padding.

      , calculateSizes = function () {
            var computedStyle = global.getComputedStyle(this.parent)
              , parentSize = this.parent[clientDimension] - parseFloat(computedStyle[paddingA]) - parseFloat(computedStyle[paddingB])

            this.size = this.a[getBoundingClientRect]()[dimension] + this.b[getBoundingClientRect]()[dimension] + this.aGutterSize + this.bGutterSize
            this.percentage = Math.min(this.size / parentSize * 100, 100)
            this.start = this.a[getBoundingClientRect]()[position]
        }

Actually adjust the size of elements a and b to offset while dragging. calc is used to allow calc(percentage + gutterpx) on the whole split instance, which allows the viewport to be resized without additional logic.

Element a's size is the same as offset. b's size is total size - a size. Both sizes are calculated from the initial parent percentage, then the gutter size is subtracted.

      , adjust = function (offset) {
            this.a.style[dimension] = calc + '(' + (offset / this.size * this.percentage) + '% - ' + this.aGutterSize + 'px)'
            this.b.style[dimension] = calc + '(' + (this.percentage - (offset / this.size * this.percentage)) + '% - ' + this.bGutterSize + 'px)'
        }

4. Define a few more functions that "balance" the entire split instance

Split.js tries it's best to cope with min sizes that don't add up. At some point this should go away since it breaks out of the calc(% - px) model. Maybe it's a user error if you pass uncomputable minSizes.

      , fitMin = function () {
            var self = this
              , a = self.a
              , b = self.b

            if (a[getBoundingClientRect]()[dimension] < self.aMin) {
                a.style[dimension] = (self.aMin - self.aGutterSize) + 'px'
                b.style[dimension] = (self.size - self.aMin - self.aGutterSize) + 'px'
            } else if (b[getBoundingClientRect]()[dimension] < self.bMin) {
                a.style[dimension] = (self.size - self.bMin - self.bGutterSize) + 'px'
                b.style[dimension] = (self.bMin - self.bGutterSize) + 'px'
            }
        }
      , fitMinReverse = function () {
            var self = this
              , a = self.a
              , b = self.b

            if (b[getBoundingClientRect]()[dimension] < self.bMin) {
                a.style[dimension] = (self.size - self.bMin - self.bGutterSize) + 'px'
                b.style[dimension] = (self.bMin - self.bGutterSize) + 'px'
            } else if (a[getBoundingClientRect]()[dimension] < self.aMin) {
                a.style[dimension] = (self.aMin - self.aGutterSize) + 'px'
                b.style[dimension] = (self.size - self.aMin - self.aGutterSize) + 'px'
            }
        }
      , balancePairs = function (pairs) {
            for (var i = 0; i < pairs.length; i++) {
                calculateSizes.call(pairs[i])
                fitMin.call(pairs[i])
            }

            for (i = pairs.length - 1; i >= 0; i--) {
                calculateSizes.call(pairs[i])
                fitMinReverse.call(pairs[i])
            }
        }

No-op function to prevent default. Used to prevent selection.

      , noop = function () { return false }

All DOM elements in the split should have a common parent. We can grab the first elements parent and hope users read the docs because the behavior will be whacky otherwise.

      , parent = elementOrSelector(ids[0]).parentNode

Set default options.sizes to equal percentages of the parent element.

    if (!options.sizes) {
        var percent = 100 / ids.length

        options.sizes = []

        for (i = 0; i < ids.length; i++) {
            options.sizes.push(percent)
        }
    }

Standardize minSize to an array if it isn't already. This allows minSize to be passed as a number.

    if (!Array.isArray(options.minSize)) {
        var minSizes = []

        for (i = 0; i < ids.length; i++) {
            minSizes.push(options.minSize)
        }

        options.minSize = minSizes
    }

5. Loop through the elements while pairing them off

Every pair gets a pair object, a gutter, and isFirst/isLast properties.

Basic logic:

  • Starting with the second element i > 0, create pair objects with a = ids[i - 1] and b = ids[i]
  • Set gutter sizes based on the element being first/last. The first and last elements have gutterSize / 2, since they only have one half gutter, and not two.
  • Create gutter elements and add event listeners.
  • Set the size of the elements, minus the gutter sizes.
-----------------------------------------------------------------------
|     i=0     |         i=1         |        i=2       |      i=3     |
|             |       isFirst       |                  |     isLast   |
|           pair 0                pair 1             pair 2           |
|             |                     |                  |              |
-----------------------------------------------------------------------
    for (i = 0; i < ids.length; i++) {
        var el = elementOrSelector(ids[i])
          , isFirstPair = (i == 1)
          , isLastPair = (i == ids.length - 1)
          , size
          , gutterSize = options.gutterSize
          , pair

        if (i > 0) {

Create the pair object with it's metadata.

            pair = {
                a: elementOrSelector(ids[i - 1]),
                b: el,
                aMin: options.minSize[i - 1],
                bMin: options.minSize[i],
                dragging: false,
                parent: parent,
                isFirst: isFirstPair,
                isLast: isLastPair,
                direction: options.direction
            }

For first and last pairs, first and last gutter width is half.

            pair.aGutterSize = options.gutterSize
            pair.bGutterSize = options.gutterSize

            if (isFirstPair) {
                pair.aGutterSize = options.gutterSize / 2
            }

            if (isLastPair) {
                pair.bGutterSize = options.gutterSize / 2
            }
        }

Determine the size of the current element. IE8 is supported by staticly assigning sizes without draggable gutters. Assigns a string to size.

IE9 and above

        if (!isIE8) {

Create gutter elements for each pair.

            if (i > 0) {
                var gutter = document.createElement('div')

                gutter.className = gutterClass
                gutter.style[dimension] = options.gutterSize + 'px'

                gutter[addEventListener]('mousedown', startDragging.bind(pair))
                gutter[addEventListener]('touchstart', startDragging.bind(pair))

                parent.insertBefore(gutter, el)

                pair.gutter = gutter
            }

Half-size gutters for first and last elements.

            if (i === 0 || i == ids.length - 1) {
                gutterSize = options.gutterSize / 2
            }

Split.js allows setting sizes via numbers (ideally), or if you must, by string, like '300px'. This is less than ideal, because it breaks the fluid layout that calc(% - px) provides. You're on your own if you do that, make sure you calculate the gutter size by hand.

            if (typeof options.sizes[i] === 'string' || options.sizes[i] instanceof String) {
                size = options.sizes[i]
            } else {
                size = calc + '(' + options.sizes[i] + '% - ' + gutterSize + 'px)'
            }

IE8 and below (static size, can not be moved)

        } else {
            if (typeof options.sizes[i] === 'string' || options.sizes[i] instanceof String) {
                size = options.sizes[i]
            } else {
                size = options.sizes[i] + '%'
            }
        }

Set the element size to our determined size.

        el.style[dimension] = size

After the first iteration, and we have a pair object, append it to the list of pairs.

        if (i > 0) {
            pairs.push(pair)
        }
    }

Balance the pairs to try to accomodate min sizes.

    balancePairs(pairs)

Play nicely with module systems, and the browser too if you include it raw.

if (typeof exports !== 'undefined') {
    if (typeof module !== 'undefined' && module.exports) {
        exports = module.exports = Split
    }
    exports.Split = Split
} else {
    global.Split = Split
}

Call our wrapper function with the current global. In this case, window.

}).call(window);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment