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:
- Doesn't pollute the global namespace. This is important for a library.
- 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:
- Set defaults to something sane.
options
doesn't have to be passed at all. - 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.
- Define the dragging helper functions, and a few helpers to go with them.
- 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.
- Loop through the elements while pairing them off. Every pair gets an
pair
object, a gutter, and special isFirst/isLast properties. - Actually size the pair elements, insert gutters and attach event listeners.
- 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 = []
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'
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'
}
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:
- Ignore if the pair is not dragging.
- Get the offset of the event.
- Snap offset to min if within snappable range (within min + snapOffset).
- 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)'
}
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
}
Every pair gets a pair
object, a gutter, and isFirst/isLast properties.
Basic logic:
- Starting with the second element
i > 0
, createpair
objects witha = ids[i - 1]
andb = 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);