converse.js
Version:
Browser based XMPP chat client
648 lines (553 loc) • 24.3 kB
JavaScript
/**
* @copyright Nathan Cahill and the Converse.js contributors
* @description Based on the split.js library from Nathan Cahill.
* @license MIT Licence
*/
import { html } from 'lit';
import { api, u } from '@converse/headless';
import { CustomElement } from './element.js';
import './styles/split-resize.scss';
const gutterStartDragging = '_a';
const aGutterSize = '_b';
const bGutterSize = '_c';
const HORIZONTAL = 'horizontal';
const NOOP = () => false;
const global = typeof window !== 'undefined' ? window : null;
export default class SplitResize extends CustomElement {
initialize() {
super.initialize();
}
constructor() {
super();
this.pair = null;
}
render() {
return html`<div class="gutter gutter-horizontal"></div>`;
}
disconnectedCallback() {
super.disconnectedCallback();
this.pair.gutter.removeEventListener('mousedown', this.pair[gutterStartDragging]);
this.pair.gutter.removeEventListener('touchstart', this.pair[gutterStartDragging]);
}
/**
* @param {Map<string, any>} changed
*/
updated(changed) {
super.updated(changed);
if (!this.pair) {
this.setupSplit([
/** @type {HTMLElement} */ (this.previousElementSibling),
/** @type {HTMLElement} */ (this.nextElementSibling),
]);
}
}
/**
* Helper function gets a property from the properties object, with a default fallback
*/
getOption(options, propName, def) {
const value = options[propName];
if (value !== undefined) {
return value;
}
return def;
}
getElementStyle(dim, size, gutSize) {
const style = {};
if (!u.isString(size)) {
style[dim] = `calc(${size}% - ${gutSize}px)`;
} else {
style[dim] = size;
}
return style;
}
defaultGutterStyleFn(dim, gutSize) {
return { [dim]: `${gutSize}px` };
}
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;
}
/**
* @param {HTMLElement} el
* @param {string} size
* @param {string} gutSize
*/
setElementSize(el, size, gutSize) {
// 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 = this.getElementStyle(this.dimension, size, gutSize);
Object.keys(style).forEach((prop) => {
// eslint-disable-next-line no-param-reassign
el.style[prop] = style[prop];
});
}
getSizes() {
return this.elements.map((element) => element.size);
}
/**
* Supports touch events, but not multitouch, so only the first
* finger `touches[0]` is counted.
* @param {MouseEvent} e
*/
getMousePosition(e) {
if ('touches' in e) return e.touches[0][this.clientAxis];
return e[this.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.
*
* @param {number} offset
*/
adjust(offset) {
const a = this.elements[this.pair.a];
const b = this.elements[this.pair.b];
const percentage = a.size + b.size;
a.size = (offset / this.pair.size) * percentage;
b.size = percentage - (offset / this.pair.size) * percentage;
this.setElementSize(a.element, a.size, this.pair[aGutterSize]);
this.setElementSize(b.element, b.size, this.pair[bGutterSize]);
}
/**
* 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 -> |
*
* @param {MouseEvent} e
* @param {object} options
*/
drag(e, options) {
let offset;
const a = this.elements[this.pair.a];
const b = this.elements[this.pair.b];
if (!this.pair.dragging) return;
// Get the offset of the event from the first side of the
// pair `pair.start`. Then offset by the initial position of the
// mouse compared to the gutter size.
offset = this.getMousePosition(e) - this.pair.start + (this.pair[aGutterSize] - this.pair.dragOffset);
if (this.dragInterval > 1) {
offset = Math.round(offset / this.dragInterval) * this.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.pair[aGutterSize]) {
offset = a.minSize + this.pair[aGutterSize];
} else if (offset >= this.pair.size - (b.minSize + b.snapOffset + this.pair[bGutterSize])) {
offset = this.pair.size - (b.minSize + this.pair[bGutterSize]);
}
if (offset >= a.maxSize - a.snapOffset + this.pair[aGutterSize]) {
offset = a.maxSize + this.pair[aGutterSize];
} else if (offset <= this.pair.size - (b.maxSize - b.snapOffset + this.pair[bGutterSize])) {
offset = this.pair.size - (b.maxSize + this.pair[bGutterSize]);
}
// Actually adjust the size.
this.adjust(offset);
// Call the drag callback continously. Don't do anything too intensive
// in pair callback.
this.getOption(options, 'onDrag', NOOP)(this.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 -> |
*
* @param {ResizablePair} pair
*/
calculateSizes(pair) {
// Figure out the parent size minus padding.
const a = this.elements[pair.a].element;
const b = this.elements[pair.b].element;
const aBounds = a.getBoundingClientRect();
const bBounds = b.getBoundingClientRect();
pair.size = aBounds[this.dimension] + bBounds[this.dimension] + pair[aGutterSize] + pair[bGutterSize];
pair.start = aBounds[this.position];
pair.end = aBounds[this.positionEnd];
}
/**
* @param {HTMLElement} el
*/
innerSize(el) {
// Return nothing if getComputedStyle is not supported (< IE9)
// Or if parent el has no layout yet
if (!getComputedStyle) return null;
const computedStyle = getComputedStyle(el);
if (!computedStyle) return null;
let size = el[this.clientSize];
if (size === 0) return null;
if (this.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.
*/
trimToMin(sizesToTrim) {
// Try to get inner size of parent element.
// If it's no supported, return original sizes.
const parentSize = this.innerSize(this.parent);
if (parentSize === null) {
return sizesToTrim;
}
if (this.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 = this.getGutterSize(
this.gutterSize,
i === 0,
i === sizesToTrim.length - 1,
this.gutterAlign
);
const elementMinSize = this.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.
* @param {Object} options
*/
stopDragging(options) {
const a = this.elements[this.pair.a].element;
const b = this.elements[this.pair.b].element;
if (this.pair.dragging) {
this.getOption(options, 'onDragEnd', NOOP)(this.getSizes());
}
this.pair.dragging = false;
// Remove the stored event listeners. This is why we store them.
global.removeEventListener('mouseup', this.pair.stop);
global.removeEventListener('touchend', this.pair.stop);
global.removeEventListener('touchcancel', this.pair.stop);
global.removeEventListener('mousemove', this.pair.move);
global.removeEventListener('touchmove', this.pair.move);
// Clear bound function references
this.pair.stop = null;
this.pair.move = null;
a.removeEventListener('selectstart', NOOP);
a.removeEventListener('dragstart', NOOP);
b.removeEventListener('selectstart', NOOP);
b.removeEventListener('dragstart', NOOP);
a.style.userSelect = '';
a.style.pointerEvents = '';
b.style.userSelect = '';
b.style.pointerEvents = '';
this.pair.gutter.style.cursor = '';
this.pair.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.
* @param {MouseEvent} e
* @param {Object} options
*/
startDragging(e, options) {
// Right-clicking can't start dragging.
if ('button' in e && e.button !== 0) {
return;
}
// Alias frequently used variables to save space. 200 bytes.
const a = this.elements[this.pair.a].element;
const b = this.elements[this.pair.b].element;
// Call the onDragStart callback.
if (!this.pair.dragging) {
this.getOption(options, 'onDragStart', NOOP)(this.getSizes());
}
// Don't actually drag the element. We emulate this in the drag function.
e.preventDefault();
// Set the dragging property of the this.pair object.
this.pair.dragging = true;
// Create two event listeners bound to the same this.pair object and store
// them in the this.pair object.
this.pair.stop = () => this.stopDragging(options);
this.pair.move = /** @param {MouseEvent} e */ (e) => this.drag(e, options);
// All the binding. `window` gets the stop events in case we drag out of the elements.
global.addEventListener('mouseup', this.pair.stop);
global.addEventListener('touchend', this.pair.stop);
global.addEventListener('touchcancel', this.pair.stop);
global.addEventListener('mousemove', this.pair.move);
global.addEventListener('touchmove', this.pair.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.pointerEvents = 'none';
b.style.userSelect = 'none';
b.style.pointerEvents = 'none';
// Set the cursor at multiple levels
this.pair.gutter.style.cursor = this.cursor;
this.pair.parent.style.cursor = this.cursor;
document.body.style.cursor = this.cursor;
// Cache the initial sizes of the this.pair.
this.calculateSizes(this.pair);
// Determine the position of the mouse compared to the gutter
this.pair.dragOffset = this.getMousePosition(e) - this.pair.end;
}
/**
* @param {Object} element
*/
adjustToMin(element) {
this.calculateSizes(this.pair);
this.adjust(this.pair.size - element.minSize - this.pair[bGutterSize]);
}
/**
* @param {Array<number>} newSizes
*/
setSizes(newSizes) {
const trimmed = this.trimToMin(newSizes);
trimmed.forEach((newSize, i) => {
if (i > 0) {
const a = this.elements[this.pair.a];
const b = this.elements[this.pair.b];
a.size = trimmed[i - 1];
b.size = newSize;
this.setElementSize(a.element, a.size, this.pair[aGutterSize]);
this.setElementSize(b.element, b.size, this.pair[bGutterSize]);
}
});
}
/**
* The main function to initialize a split.
*
* Each pair of elements, resizable relative to one another, is handled independently.
* Dragging the gutter between two elements only changes the dimensions of elements in that pair.
*
* A pair object is shaped like this:
*
* @typedef {Object} ResizablePair
* @property {(0|1)} a
* @property {(0|1)} b
* @property {('horizontal'|'vertical')} direction
* @property {boolean} dragging
* @property {number} aMin
* @property {number} bMin
* @property {number} dragOffset
* @property {number} size
* @property {number} start
* @property {number} end
* @property {HTMLElement} gutter
* @property {HTMLElement} parent
* @property {(this: Window, ev: Event) => any} stop
* @property {(this: Window, ev: Event) => any} move
*
* 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.
*
* @param {HTMLElement[]} els
*/
setupSplit(els, options = {}) {
// Allow HTMLCollection to be used as an argument
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];
this.parent = firstElement.parentElement;
// 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 = this.getOption(options, 'minSize', 100);
this.minSizes = Array.isArray(minSize) ? minSize : els.map(() => minSize);
// Get other options
const expandToMin = this.getOption(options, 'expandToMin', false);
this.gutterSize = this.getOption(options, 'gutterSize', 5);
this.gutterAlign = this.getOption(options, 'gutterAlign', 'center');
this.dragInterval = this.getOption(options, 'dragInterval', 1);
this.direction = this.getOption(options, 'direction', HORIZONTAL);
this.cursor = this.getOption(options, 'cursor', this.direction === HORIZONTAL ? 'col-resize' : 'row-resize');
// 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 (this.direction === HORIZONTAL) {
this.dimension = 'width';
this.clientAxis = 'clientX';
this.position = 'left';
this.positionEnd = 'right';
this.clientSize = 'clientWidth';
} else if (this.direction === 'vertical') {
this.dimension = 'height';
this.clientAxis = 'clientY';
this.position = 'top';
this.positionEnd = 'bottom';
this.clientSize = 'clientHeight';
}
// 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 |
// | | | | |
// -----------------------------------------------------------------------
this.elements = els.map((el, i) => this.createElement(els, el, i, options));
this.elements.forEach((element) => {
const computedSize = element.element.getBoundingClientRect()[this.dimension];
if (computedSize < element.minSize) {
if (expandToMin) {
this.adjustToMin(element);
} else {
element.minSize = computedSize;
}
}
});
this.createPair(options);
}
/**
* @param {HTMLElement[]} els
* @param {HTMLElement} el
* @param {number} i
* @param {object} options
*/
createElement(els, el, i, options) {
// Set default options.sizes to equal percentages of the parent element.
let sizes = this.getOption(options, 'sizes') || els.map(() => 100 / els.length);
// adjust sizes to ensure percentage is within min size and gutter.
sizes = this.trimToMin(sizes);
const maxSize = this.getOption(options, 'maxSize', Infinity);
const maxSizes = Array.isArray(maxSize) ? maxSize : els.map(() => maxSize);
const snapOffset = this.getOption(options, 'snapOffset', 30);
const snapOffsets = Array.isArray(snapOffset) ? snapOffset : els.map(() => snapOffset);
return {
element: el,
size: sizes[i],
minSize: this.minSizes[i],
maxSize: maxSizes[i],
snapOffset: snapOffsets[i],
i,
};
}
/**
* @param {object} options
*/
createPair(options) {
const parentStyle = getComputedStyle ? getComputedStyle(this.parent) : null;
const parentFlexDirection = parentStyle ? parentStyle.flexDirection : null;
// Create the pair object with its metadata.
this.pair = /** @type {ResizablePair} */ ({
a: 0,
b: 1,
dragging: false,
direction: this.direction,
parent: this.parent,
});
this.pair[aGutterSize] = this.getGutterSize(this.gutterSize, true, false, this.gutterAlign);
this.pair[bGutterSize] = this.getGutterSize(this.gutterSize, false, true, this.gutterAlign);
// if the parent has a reverse flex-direction, switch the pair elements.
if (parentFlexDirection === 'row-reverse' || parentFlexDirection === 'column-reverse') {
const temp = this.pair.a;
this.pair.a = this.pair.b;
this.pair.b = temp;
}
const gutterElement = /** @type {HTMLElement} */ (this.firstElementChild);
// Save bound event listener for removal later
this.pair[gutterStartDragging] = (e) => this.startDragging(e, options);
// Attach bound event listener
this.addEventListener('mousedown', this.pair[gutterStartDragging]);
this.addEventListener('touchstart', this.pair[gutterStartDragging]);
this.pair.gutter = gutterElement;
}
}
api.elements.define('converse-split-resize', SplitResize);