Browse Source

Work on fixing dragresize for the occupants sidebar

JC Brand 8 tháng trước cách đây
mục cha
commit
da47a3b822

+ 3 - 1
src/plugins/dragresize/mixin.js

@@ -65,8 +65,10 @@ const DragResizableMixin = {
     setDimensions () {
         // Make sure the chat box has the right height and width.
         this.adjustToViewport();
-        this.setChatBoxHeight(this.model.get('height'));
         this.setChatBoxWidth(this.model.get('width'));
+        if (api.settings.get('view_mode') === 'overlayed') {
+            this.setChatBoxHeight(this.model.get('height'));
+        }
     },
 
     setChatBoxHeight (height) {

+ 1 - 0
src/plugins/dragresize/utils.js

@@ -95,6 +95,7 @@ export function onStartHorizontalResize(ev, trigger = true) {
     ev.preventDefault();
     const flyout = u.ancestor(ev.target, '.box-flyout');
     const style = window.getComputedStyle(flyout);
+
     const chatbox_el = flyout.parentElement;
     chatbox_el.width = parseInt(style.width.replace(/px$/, ''), 10);
     resizing.chatbox = chatbox_el;

+ 17 - 94
src/plugins/muc-views/chatarea.js

@@ -1,10 +1,9 @@
 import { api, converse } from '@converse/headless';
 import { __ } from 'i18n';
+import Split from 'shared/split.js';
 import { CustomElement } from 'shared/components/element.js';
 import tplMUCChatarea from './templates/muc-chatarea.js';
 
-const { u } = converse.env;
-
 
 export default class MUCChatArea extends CustomElement {
 
@@ -20,6 +19,7 @@ export default class MUCChatArea extends CustomElement {
         super();
         this.jid = null;
         this.type = null;
+        this.split = null;
     }
 
     async initialize () {
@@ -28,17 +28,27 @@ export default class MUCChatArea extends CustomElement {
         this.listenTo(this.model, 'change:hidden_occupants', () => this.requestUpdate());
         this.listenTo(this.model.session, 'change:connection_status', () => this.requestUpdate());
 
-        // Bind so that we can pass it to addEventListener and removeEventListener
-        this.onMouseMove = this._onMouseMove.bind(this);
-        this.onMouseUp = this._onMouseUp.bind(this);
-
-        this.requestUpdate(); // Make sure we render again after the model has been attached
+        this.requestUpdate();
     }
 
     render () {
         return this.model ? tplMUCChatarea(this) : '';
     }
 
+    /**
+     * @param {Map<string, any>} changed
+     */
+    updated(changed) {
+        super.updated(changed);
+        if (!this.split) {
+            const sidebar_el = this.querySelector('converse-muc-sidebar');
+            if (sidebar_el) {
+                const chatarea_el = this.querySelector('.chat-area');
+                this.split = Split([chatarea_el, sidebar_el]);
+            }
+        }
+    }
+
     shouldShowSidebar () {
         return (
             !this.model.get('hidden_occupants') &&
@@ -74,93 +84,6 @@ export default class MUCChatArea extends CustomElement {
             .filter(line => disabled_commands.every(c => !line.startsWith(c + '<', 9)))
             .filter(line => this.model.getAllowedCommands().some(c => line.startsWith(c + '<', 9)));
     }
-
-    /**
-     * @param {MouseEvent} ev
-     */
-    onMousedown (ev) {
-        if (u.hasClass('dragresize-occupants-left', ev.target)) {
-            this.onStartResizeOccupants(ev);
-        }
-    }
-
-    /**
-     * @param {MouseEvent} ev
-     */
-    onStartResizeOccupants (ev) {
-        this.resizing = true;
-        this.addEventListener('mousemove', this.onMouseMove);
-        this.addEventListener('mouseup', this.onMouseUp);
-
-        const sidebar_el = this.querySelector('converse-muc-sidebar');
-        const style = window.getComputedStyle(sidebar_el);
-        this.width = parseInt(style.width.replace(/px$/, ''), 10);
-        this.prev_pageX = ev.pageX;
-    }
-
-    /**
-     * @param {MouseEvent} ev
-     */
-    _onMouseMove (ev) {
-        if (this.resizing) {
-            ev.preventDefault();
-            const delta = this.prev_pageX - ev.pageX;
-            this.resizeSidebarView(delta, ev.pageX);
-            this.prev_pageX = ev.pageX;
-        }
-    }
-
-    /**
-     * @param {MouseEvent} ev
-     */
-    _onMouseUp (ev) {
-        if (this.resizing) {
-            ev.preventDefault();
-            this.resizing = false;
-            this.removeEventListener('mousemove', this.onMouseMove);
-            this.removeEventListener('mouseup', this.onMouseUp);
-            const sidebar_el = this.querySelector('converse-muc-sidebar');
-            const element_position = sidebar_el.getBoundingClientRect();
-            const occupants_width = this.calculateSidebarWidth(element_position, 0);
-            u.safeSave(this.model, { occupants_width });
-        }
-    }
-
-    calculateSidebarWidth (element_position, delta) {
-        let occupants_width = element_position.width + delta;
-        const room_width = this.clientWidth;
-        // keeping display in boundaries
-        if (occupants_width < room_width * 0.2) {
-            // set pixel to 20% width
-            occupants_width = room_width * 0.2;
-            this.is_minimum = true;
-        } else if (occupants_width > room_width * 0.75) {
-            // set pixel to 75% width
-            occupants_width = room_width * 0.75;
-            this.is_maximum = true;
-        } else if (room_width - occupants_width < 250) {
-            // resize occupants if chat-area becomes smaller than 250px (min-width property set in css)
-            occupants_width = room_width - 250;
-            this.is_maximum = true;
-        } else {
-            this.is_maximum = false;
-            this.is_minimum = false;
-        }
-        return occupants_width;
-    }
-
-    resizeSidebarView (delta, current_mouse_position) {
-        const sidebar_el = /** @type {HTMLElement} */(this.querySelector('converse-muc-sidebar'));
-        const element_position = sidebar_el.getBoundingClientRect();
-        if (this.is_minimum) {
-            this.is_minimum = element_position.left < current_mouse_position;
-        } else if (this.is_maximum) {
-            this.is_maximum = element_position.left > current_mouse_position;
-        } else {
-            const occupants_width = this.calculateSidebarWidth(element_position, delta);
-            sidebar_el.style.flex = '0 0 ' + occupants_width + 'px';
-        }
-    }
 }
 
 api.elements.define('converse-muc-chatarea', MUCChatArea);

+ 7 - 3
src/plugins/muc-views/styles/muc-occupant.scss

@@ -2,9 +2,13 @@
     converse-muc-occupant {
         width: 100%;
 
-        .back-button {
-            font-size: 0.75em;
-            padding: 0.5em;
+        .sidebar-heading {
+            padding-top: 0.4em;
+            .back-button {
+                font-size: 0.75em;
+                padding: 0.5em;
+                padding-top: 0.2em;
+            }
         }
 
         .occupant-details {

+ 0 - 1
src/plugins/muc-views/styles/muc-sidebar.scss

@@ -5,7 +5,6 @@
         flex-direction: row;
         overflow: visible;
         background-color: var(--background-color);
-        border-left: 0.25em solid var(--muc-color);
 
         .sidebar-heading {
             width: 100%;

+ 4 - 0
src/plugins/muc-views/styles/muc.scss

@@ -20,6 +20,10 @@
             }
         }
 
+        .gutter {
+            background-color: var(--muc-color);
+        }
+
         .chat-status--avatar {
             background: var(--background-color);
             border: 2px solid var(--background-color);

+ 1 - 2
src/plugins/muc-views/templates/muc-chatarea.js

@@ -45,6 +45,5 @@ export default (el) => {
         ${el.model ? html`
             <converse-muc-sidebar
                 class="${el.shouldShowSidebar() ? sidebar_classes : 'col-xs-0 hidden' }"
-                jid=${el.jid}
-                @mousedown=${(ev) => el.onMousedown(ev)}></converse-muc-sidebar>` : '' }`
+                jid=${el.jid}></converse-muc-sidebar>` : '' }`
 };

+ 3 - 5
src/plugins/muc-views/templates/muc-occupant.js

@@ -27,16 +27,14 @@ export default (el) => {
             .then(contact => !contact && not_me && can_see_real_jids)
             .then(add => add ? html`<li><button class="btn btn-primary" type="button" @click=${() => el.addToContacts()}>${i18n_add_to_contacts}</button></li>` : '');
 
-    return html`<span class="sidebar-heading">
+    return html`<div class="sidebar-heading">
             <button
                 type="button"
                 class="btn btn--transparent back-button"
-                @click=${() => el.muc.save({ 'sidebar_view': 'occupants' })}
-            >
+                @click=${() => el.muc.save({ 'sidebar_view': 'occupants' })}>
                 <converse-icon size="1em" class="fa fa-arrow-left"></converse-icon>
             </button>
-            ${i18n_participants}</span
-        >
+            ${i18n_participants}</div>
 
         ${el.model ? html`
             <div class="row">

+ 0 - 1
src/plugins/muc-views/templates/muc-sidebar.js

@@ -8,7 +8,6 @@ export default (el) => {
     const sidebar_view = model.get('sidebar_view') || '';
     const occupant_id = sidebar_view.split('occupant:').pop();
     return html`
-        <div class="dragresize-occupants-left">&nbsp;</div>
         ${sidebar_view?.startsWith('occupant:')
             ? html`<converse-muc-occupant muc_jid="${el.jid}" occupant_id="${occupant_id}"></converse-muc-occupant>`
             : html`<converse-muc-occupants jid="${el.jid}"></converse-muc-occupants>`}

+ 690 - 0
src/shared/split.js

@@ -0,0 +1,690 @@
+/**
+ * @copyright Nathan Cahill and the Converse.js contributors
+ * @description Based on the split.js library from Nathan Cahill.
+ * @license MIT Licence
+ */
+const global = typeof window !== 'undefined' ? window : null;
+const ssr = global === null;
+const document = !ssr ? global.document : undefined;
+
+// Save a couple long function names that are used frequently.
+// This optimization saves around 400 bytes.
+const addEventListener = 'addEventListener';
+const removeEventListener = 'removeEventListener';
+const getBoundingClientRect = 'getBoundingClientRect';
+const gutterStartDragging = '_a';
+const aGutterSize = '_b';
+const bGutterSize = '_c';
+const HORIZONTAL = 'horizontal';
+const NOOP = () => false;
+
+// Helper function 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 StackOverflow:
+// http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167
+const calc = ssr
+    ? 'calc'
+    : `${['', '-webkit-', '-moz-', '-o-']
+          .filter((prefix) => {
+              const el = document.createElement('div');
+              el.style.cssText = `width:${prefix}calc(9px)`;
+
+              return !!el.style.length;
+          })
+          .shift()}calc`;
+
+// Helper function checks if its argument is a string-like type
+const isString = (v) => typeof v === 'string' || v instanceof String;
+
+// Helper function gets a property from the properties object, with a default fallback
+const getOption = (options, propName, def) => {
+    const value = options[propName];
+    if (value !== undefined) {
+        return value;
+    }
+    return def;
+};
+
+const getGutterSize = (gutterSize, isFirst, isLast, gutterAlign) => {
+    if (isFirst) {
+        if (gutterAlign === 'end') {
+            return 0;
+        }
+        if (gutterAlign === 'center') {
+            return gutterSize / 2;
+        }
+    } else if (isLast) {
+        if (gutterAlign === 'start') {
+            return 0;
+        }
+        if (gutterAlign === 'center') {
+            return gutterSize / 2;
+        }
+    }
+
+    return gutterSize;
+};
+
+// Default options
+const defaultGutterFn = (i, gutterDirection) => {
+    const gut = document.createElement('div');
+    gut.className = `gutter gutter-${gutterDirection}`;
+    return gut;
+};
+
+const defaultElementStyleFn = (dim, size, gutSize) => {
+    const style = {};
+
+    if (!isString(size)) {
+        style[dim] = `${calc}(${size}% - ${gutSize}px)`;
+    } else {
+        style[dim] = size;
+    }
+
+    return style;
+};
+
+const defaultGutterStyleFn = (dim, gutSize) => ({ [dim]: `${gutSize}px` });
+
+/**
+ * The main function to initialize a split. Split.js thinks about each pair
+ * of elements as an independent 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,
+ *     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 parameterized down to
+ *    rely on CSS strings and classes.
+ * 3. Define the dragging helper functions, and a few helpers to go with them.
+ * 4. Loop through the elements while pairing them off. Every pair gets an
+ *    `pair` object and a gutter.
+ * 5. Actually size the pair elements, insert gutters and attach event listeners.
+ */
+const Split = (els, options = {}) => {
+    if (ssr) return {};
+
+    let dimension;
+    let clientAxis;
+    let position;
+    let positionEnd;
+    let clientSize;
+    let elements;
+
+    // Allow HTMLCollection to be used as an argument when supported
+    if (Array.from) {
+        els = Array.from(els);
+    }
+
+    // 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.
+    const firstElement = els[0];
+    const parent = firstElement.parentNode;
+    const parentStyle = getComputedStyle ? getComputedStyle(parent) : null;
+    const parentFlexDirection = parentStyle ? parentStyle.flexDirection : null;
+
+    // Set default options.sizes to equal percentages of the parent element.
+    let sizes = getOption(options, 'sizes') || els.map(() => 100 / els.length);
+
+    // Standardize minSize and maxSize to an array if it isn't already.
+    // This allows minSize and maxSize to be passed as a number.
+    const minSize = getOption(options, 'minSize', 100);
+    const minSizes = Array.isArray(minSize) ? minSize : els.map(() => minSize);
+    const maxSize = getOption(options, 'maxSize', Infinity);
+    const maxSizes = Array.isArray(maxSize) ? maxSize : els.map(() => maxSize);
+
+    // Get other options
+    const expandToMin = getOption(options, 'expandToMin', false);
+    const gutterSize = getOption(options, 'gutterSize', 5);
+    const gutterAlign = getOption(options, 'gutterAlign', 'center');
+    const snapOffset = getOption(options, 'snapOffset', 30);
+    const snapOffsets = Array.isArray(snapOffset) ? snapOffset : els.map(() => snapOffset);
+    const dragInterval = getOption(options, 'dragInterval', 1);
+    const direction = getOption(options, 'direction', HORIZONTAL);
+    const cursor = getOption(options, 'cursor', direction === HORIZONTAL ? 'col-resize' : 'row-resize');
+    const gutter = getOption(options, 'gutter', defaultGutterFn);
+    const elementStyle = getOption(options, 'elementStyle', defaultElementStyleFn);
+    const gutterStyle = getOption(options, 'gutterStyle', defaultGutterStyleFn);
+
+    // 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 (direction === HORIZONTAL) {
+        dimension = 'width';
+        clientAxis = 'clientX';
+        position = 'left';
+        positionEnd = 'right';
+        clientSize = 'clientWidth';
+    } else if (direction === 'vertical') {
+        dimension = 'height';
+        clientAxis = 'clientY';
+        position = 'top';
+        positionEnd = 'bottom';
+        clientSize = 'clientHeight';
+    }
+
+    // 3. Define the dragging helper functions, and a few helpers to go with them.
+    // Each helper is bound to a pair object that contains its 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.
+
+    function setElementSize(el, size, gutSize, i) {
+        // 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.
+        const style = elementStyle(dimension, size, gutSize, i);
+
+        Object.keys(style).forEach((prop) => {
+            // eslint-disable-next-line no-param-reassign
+            el.style[prop] = style[prop];
+        });
+    }
+
+    function setGutterSize(gutterElement, gutSize, i) {
+        const style = gutterStyle(dimension, gutSize, i);
+
+        Object.keys(style).forEach((prop) => {
+            // eslint-disable-next-line no-param-reassign
+            gutterElement.style[prop] = style[prop];
+        });
+    }
+
+    function getSizes() {
+        return elements.map((element) => element.size);
+    }
+
+    // Supports touch events, but not multitouch, so only the first
+    // finger `touches[0]` is counted.
+    function getMousePosition(e) {
+        if ('touches' in e) return e.touches[0][clientAxis];
+        return e[clientAxis];
+    }
+
+    /**
+     * 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.
+     */
+    function adjust(offset) {
+        const a = elements[this.a];
+        const b = elements[this.b];
+        const percentage = a.size + b.size;
+
+        a.size = (offset / this.size) * percentage;
+        b.size = percentage - (offset / this.size) * percentage;
+
+        setElementSize(a.element, a.size, this[aGutterSize], a.i);
+        setElementSize(b.element, b.size, this[bGutterSize], b.i);
+    }
+
+    /**
+     * Handles the dragging logic for resizing elements.
+     *
+     * The logic is 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.
+     *
+     * ---------------------------------------------------------------------
+     * |    | <- a.minSize               ||              b.minSize -> |    |
+     * |    |  | <- this.snapOffset      ||     this.snapOffset -> |  |    |
+     * |    |  |                         ||                        |  |    |
+     * |    |  |                         ||                        |  |    |
+     * ---------------------------------------------------------------------
+     * | <- this.start                                        this.size -> |
+     */
+    function drag(e) {
+        let offset;
+        const a = elements[this.a];
+        const b = elements[this.b];
+
+        if (!this.dragging) return;
+
+        // Get the offset of the event from the first side of the
+        // pair `this.start`. Then offset by the initial position of the
+        // mouse compared to the gutter size.
+        offset = getMousePosition(e) - this.start + (this[aGutterSize] - this.dragOffset);
+
+        if (dragInterval > 1) {
+            offset = Math.round(offset / dragInterval) * dragInterval;
+        }
+
+        // If within snapOffset of min or max, set offset to min or max.
+        // snapOffset buffers a.minSize and b.minSize, so logic is opposite for both.
+        // Include the appropriate gutter sizes to prevent overflows.
+        if (offset <= a.minSize + a.snapOffset + this[aGutterSize]) {
+            offset = a.minSize + this[aGutterSize];
+        } else if (offset >= this.size - (b.minSize + b.snapOffset + this[bGutterSize])) {
+            offset = this.size - (b.minSize + this[bGutterSize]);
+        }
+
+        if (offset >= a.maxSize - a.snapOffset + this[aGutterSize]) {
+            offset = a.maxSize + this[aGutterSize];
+        } else if (offset <= this.size - (b.maxSize - b.snapOffset + this[bGutterSize])) {
+            offset = this.size - (b.maxSize + this[bGutterSize]);
+        }
+
+        // Actually adjust the size.
+        adjust.call(this, offset);
+
+        // Call the drag callback continously. Don't do anything too intensive
+        // in this callback.
+        getOption(options, 'onDrag', NOOP)(getSizes());
+    }
+
+    /**
+     * Cache some important sizes when drag starts, so we don't have to do that
+     * continuously:
+     *
+     * `size`: The total size of the pair. First + second + first gutter + second gutter.
+     * `start`: The leading side of the first element.
+     *
+     * ------------------------------------------------
+     * |      aGutterSize -> |||                      |
+     * |                     |||                      |
+     * |                     |||                      |
+     * |                     ||| <- bGutterSize       |
+     * ------------------------------------------------
+     * | <- start                             size -> |
+     */
+    function calculateSizes() {
+        // Figure out the parent size minus padding.
+        const a = elements[this.a].element;
+        const b = elements[this.b].element;
+
+        const aBounds = a[getBoundingClientRect]();
+        const bBounds = b[getBoundingClientRect]();
+
+        this.size = aBounds[dimension] + bBounds[dimension] + this[aGutterSize] + this[bGutterSize];
+        this.start = aBounds[position];
+        this.end = aBounds[positionEnd];
+    }
+
+    function innerSize(element) {
+        // Return nothing if getComputedStyle is not supported (< IE9)
+        // Or if parent element has no layout yet
+        if (!getComputedStyle) return null;
+
+        const computedStyle = getComputedStyle(element);
+
+        if (!computedStyle) return null;
+
+        let size = element[clientSize];
+
+        if (size === 0) return null;
+
+        if (direction === HORIZONTAL) {
+            size -= parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight);
+        } else {
+            size -= parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom);
+        }
+
+        return size;
+    }
+
+    /**
+     * When specifying percentage sizes that are less than the computed
+     * size of the element minus the gutter, the lesser percentages must be increased
+     * (and decreased from the other elements) to make space for the pixels
+     * subtracted by the gutters.
+     */
+    function trimToMin(sizesToTrim) {
+        // Try to get inner size of parent element.
+        // If it's no supported, return original sizes.
+        const parentSize = innerSize(parent);
+        if (parentSize === null) {
+            return sizesToTrim;
+        }
+
+        if (minSizes.reduce((a, b) => a + b, 0) > parentSize) {
+            return sizesToTrim;
+        }
+
+        // Keep track of the excess pixels, the amount of pixels over the desired percentage
+        // Also keep track of the elements with pixels to spare, to decrease after if needed
+        let excessPixels = 0;
+        const toSpare = [];
+
+        const pixelSizes = sizesToTrim.map((size, i) => {
+            // Convert requested percentages to pixel sizes
+            const pixelSize = (parentSize * size) / 100;
+            const elementGutterSize = getGutterSize(gutterSize, i === 0, i === sizesToTrim.length - 1, gutterAlign);
+            const elementMinSize = minSizes[i] + elementGutterSize;
+
+            // If element is too smal, increase excess pixels by the difference
+            // and mark that it has no pixels to spare
+            if (pixelSize < elementMinSize) {
+                excessPixels += elementMinSize - pixelSize;
+                toSpare.push(0);
+                return elementMinSize;
+            }
+
+            // Otherwise, mark the pixels it has to spare and return it's original size
+            toSpare.push(pixelSize - elementMinSize);
+            return pixelSize;
+        });
+
+        // If nothing was adjusted, return the original sizes
+        if (excessPixels === 0) {
+            return sizesToTrim;
+        }
+
+        return pixelSizes.map((pixelSize, i) => {
+            let newPixelSize = pixelSize;
+
+            // While there's still pixels to take, and there's enough pixels to spare,
+            // take as many as possible up to the total excess pixels
+            if (excessPixels > 0 && toSpare[i] - excessPixels > 0) {
+                const takenPixels = Math.min(excessPixels, toSpare[i] - excessPixels);
+
+                // Subtract the amount taken for the next iteration
+                excessPixels -= takenPixels;
+                newPixelSize = pixelSize - takenPixels;
+            }
+
+            // Return the pixel size adjusted as a percentage
+            return (newPixelSize / parentSize) * 100;
+        });
+    }
+
+    // stopDragging is very similar to startDragging in reverse.
+    function stopDragging() {
+        const self = this;
+        const a = elements[self.a].element;
+        const b = elements[self.b].element;
+
+        if (self.dragging) {
+            getOption(options, 'onDragEnd', NOOP)(getSizes());
+        }
+
+        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);
+        global[removeEventListener]('mousemove', self.move);
+        global[removeEventListener]('touchmove', self.move);
+
+        // Clear bound function references
+        self.stop = null;
+        self.move = null;
+
+        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 = '';
+        document.body.style.cursor = '';
+    }
+
+    /**
+     * startDragging calls `calculateSizes` to store the initial size in the pair object.
+     * It also adds event listeners for mouse/touch events,
+     * and prevents selection while dragging to avoid selecting text.
+     */
+    function startDragging(e) {
+        // Right-clicking can't start dragging.
+        if ('button' in e && e.button !== 0) {
+            return;
+        }
+
+        // Alias frequently used variables to save space. 200 bytes.
+        const self = this;
+        const a = elements[self.a].element;
+        const b = elements[self.b].element;
+
+        // Call the onDragStart callback.
+        if (!self.dragging) {
+            getOption(options, 'onDragStart', NOOP)(getSizes());
+        }
+
+        // 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);
+        global[addEventListener]('mousemove', self.move);
+        global[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 at multiple levels
+        self.gutter.style.cursor = cursor;
+        self.parent.style.cursor = cursor;
+        document.body.style.cursor = cursor;
+
+        // Cache the initial sizes of the pair.
+        calculateSizes.call(self);
+
+        // Determine the position of the mouse compared to the gutter
+        self.dragOffset = getMousePosition(e) - self.end;
+    }
+
+    // adjust sizes to ensure percentage is within min size and gutter.
+    sizes = trimToMin(sizes);
+
+    // 5. Create pair and element objects. Each pair has an index reference to
+    // elements `a` and `b` of the pair (first and second elements).
+    // Loop through the elements while pairing them off. Every pair gets a
+    // `pair` object and a gutter.
+    //
+    // Basic logic:
+    //
+    // - Starting with the second element `i > 0`, create `pair` objects with
+    //   `a = i - 1` and `b = i`
+    // - Set gutter sizes based on the _pair_ being first/last. The first and last
+    //   pair 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     |
+    // |             |                     |                  |              |
+    // |           pair 0                pair 1             pair 2           |
+    // |             |                     |                  |              |
+    // -----------------------------------------------------------------------
+    const pairs = [];
+    elements = els.map((el, i) => {
+        // Create the element object.
+        const element = {
+            element: el,
+            size: sizes[i],
+            minSize: minSizes[i],
+            maxSize: maxSizes[i],
+            snapOffset: snapOffsets[i],
+            i,
+        };
+
+        let pair;
+
+        if (i > 0) {
+            // Create the pair object with its metadata.
+            pair = {
+                a: i - 1,
+                b: i,
+                dragging: false,
+                direction,
+                parent,
+            };
+
+            pair[aGutterSize] = getGutterSize(gutterSize, i - 1 === 0, false, gutterAlign);
+            pair[bGutterSize] = getGutterSize(gutterSize, false, i === els.length - 1, gutterAlign);
+
+            // if the parent has a reverse flex-direction, switch the pair elements.
+            if (parentFlexDirection === 'row-reverse' || parentFlexDirection === 'column-reverse') {
+                const temp = pair.a;
+                pair.a = pair.b;
+                pair.b = temp;
+            }
+        }
+
+        // Determine the size of the current element. IE8 is supported by
+        // staticly assigning sizes without draggable gutters. Assigns a string
+        // to `size`.
+        //
+        // Create gutter elements for each pair.
+        if (i > 0) {
+            const gutterElement = gutter(i, direction, element.element);
+            setGutterSize(gutterElement, gutterSize, i);
+
+            // Save bound event listener for removal later
+            pair[gutterStartDragging] = startDragging.bind(pair);
+
+            // Attach bound event listener
+            gutterElement[addEventListener]('mousedown', pair[gutterStartDragging]);
+            gutterElement[addEventListener]('touchstart', pair[gutterStartDragging]);
+
+            parent.insertBefore(gutterElement, element.element);
+
+            pair.gutter = gutterElement;
+        }
+
+        // After the first iteration, and we have a pair object, append it to the
+        // list of pairs.
+        if (i > 0) {
+            pairs.push(pair);
+        }
+
+        return element;
+    });
+
+    function adjustToMin(element) {
+        const isLast = element.i === pairs.length;
+        const pair = isLast ? pairs[element.i - 1] : pairs[element.i];
+
+        calculateSizes.call(pair);
+
+        const size = isLast ? pair.size - element.minSize - pair[bGutterSize] : element.minSize + pair[aGutterSize];
+
+        adjust.call(pair, size);
+    }
+
+    elements.forEach((element) => {
+        const computedSize = element.element[getBoundingClientRect]()[dimension];
+
+        if (computedSize < element.minSize) {
+            if (expandToMin) {
+                adjustToMin(element);
+            } else {
+                // eslint-disable-next-line no-param-reassign
+                element.minSize = computedSize;
+            }
+        }
+    });
+
+    function setSizes(newSizes) {
+        const trimmed = trimToMin(newSizes);
+        trimmed.forEach((newSize, i) => {
+            if (i > 0) {
+                const pair = pairs[i - 1];
+
+                const a = elements[pair.a];
+                const b = elements[pair.b];
+
+                a.size = trimmed[i - 1];
+                b.size = newSize;
+
+                setElementSize(a.element, a.size, pair[aGutterSize], a.i);
+                setElementSize(b.element, b.size, pair[bGutterSize], b.i);
+            }
+        });
+    }
+
+    function destroy(preserveStyles, preserveGutter) {
+        pairs.forEach((pair) => {
+            if (preserveGutter !== true) {
+                pair.parent.removeChild(pair.gutter);
+            } else {
+                pair.gutter[removeEventListener]('mousedown', pair[gutterStartDragging]);
+                pair.gutter[removeEventListener]('touchstart', pair[gutterStartDragging]);
+            }
+
+            if (preserveStyles !== true) {
+                const style = elementStyle(dimension, pair.a.size, pair[aGutterSize]);
+
+                Object.keys(style).forEach((prop) => {
+                    elements[pair.a].element.style[prop] = '';
+                    elements[pair.b].element.style[prop] = '';
+                });
+            }
+        });
+    }
+
+    return {
+        setSizes,
+        getSizes,
+        collapse(i) {
+            adjustToMin(elements[i]);
+        },
+        destroy,
+        parent,
+        pairs,
+    };
+};
+
+export default Split;

+ 11 - 0
src/shared/styles/_core.scss

@@ -4,6 +4,17 @@
     font-size: var(--font-size);
     direction: ltr;
 
+    .gutter {
+        background-color: #eee;
+        background-repeat: no-repeat;
+        background-position: 50%;
+
+        &.gutter-horizontal {
+            background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
+            cursor: col-resize;
+        }
+    }
+
     .flyout {
         position: absolute;
     }