@purtuga/interactions
Version:
Re-usable UI interactions.
290 lines (255 loc) • 9.53 kB
JavaScript
import {objectExtend} from "@purtuga/common/src/jsutils/objectExtend.js"
import {dataStore} from "@purtuga/common/src/jsutils/dataStore.js"
import {eventful} from "@purtuga/common/src/jsutils/Ev-decorator.js"
import {bound} from "@purtuga/common/src/jsutils/bound-decorator.js"
import {domAddEventListener} from "@purtuga/common/src/domutils/domAddEventListener.js"
import {BaseClass} from "@purtuga/common/src/jsutils/BaseClass.js"
import {
createElement,
doc
} from "@purtuga/common/src/jsutils/runtime-aliases.js";
//========================================================================
const PRIVATE = dataStore.create();
const IS_MINE = Symbol("resizable.handle");
const EV_RESIZE = "resize";
const EV_START = `${EV_RESIZE}-start`;
const EV_END = `${EV_RESIZE}-end`;
const HANDLE_OPTIONS = [
"nHandle",
"neHandle",
"eHandle",
"seHandle",
"sHandle",
"swHandle",
"wHandle",
"nwHandle"
];
const IS_NORTH_SOUTH_MOVEMENT = /^[ns]H/;
const IS_EAST_WEST_MOVEMENT = /^[ew]H/;
const IS_TOUCH = /^touch/;
const RETURN_FALSE = () => false;
const handleTemplate = createElement("template");
handleTemplate.innerHTML = `<div handle></div>`;
/**
* Utility to make a given DOM element resizable by dragging its edges.
* Utility is very low level and does not apply required styles to html handles that are inserted
* into provided elements.
*
* @class Resizable
* @extends BaseClass
* @implements MinimalEventEmitter
*
* @param {Object} options
*
* @param {HTMLElement} options.ele
* The HTML Element that should be made resizable. Elements's `width` and `height` style attributes
* will be manipulated.
*
* @param {HTMLElement} [options.minWidth=50]
*
* @param {HTMLElement} [options.minHeight=50]
*
* @param {String} [options.handleClassPrefix="handle"]
* The CSS Prefix for the handles. Used only on handles whose configuration (see below) is `true`;
*
* @param {Boolean|String|HTMLElement} [options.nHandle=false]
* The top (n = North) drag handle. The following option values applies to all handles:
* - `Boolean`: indicating if handle should be used or not. In such case,
* this utility will insert its own markup to represent the handle.
* Note that this option will require that Styles be added to the page in order
* to ensure the handles are styles correctly.
* - `String`: a selector identifing the drag handle (selector is applied
* to `options.ele`)
* - `HTMLElement`: An HTML element
* @param {Boolean|String|HTMLElement} [options.neHandle=false]
* @param {Boolean|String|HTMLElement} [options.eHandle=false]
* @param {Boolean|String|HTMLElement} [options.seHandle=false]
* @param {Boolean|String|HTMLElement} [options.sHandle=false]
* @param {Boolean|String|HTMLElement} [options.swHandle=false]
* @param {Boolean|String|HTMLElement} [options.wHandle=false]
* @param {Boolean|String|HTMLElement} [options.nwHandle=false]
*
* @fires Resizable#resize-start
* @fires Resizable#resize-end
* @fires Resizable#resize
*/
class Resizable extends BaseClass {
constructor(options) {
super();
let inst = {
opt: objectExtend({}, this.constructor.defaults, options)
};
PRIVATE.set(this, inst);
inst.opt.ele.ondragstart = RETURN_FALSE;
this.emit = this.emit.bind(this);
setupHandles.call(this);
}
}
function setupHandles() {
let inst = PRIVATE.get(this);
let opt = inst.opt;
let handleEle;
HANDLE_OPTIONS.forEach(handle => {
handleEle = null;
const handleType = typeof opt[handle];
if (!opt[handle]) {
return;
} else if ("boolean" === handleType) {
handleEle = document.importNode(handleTemplate.content, true).firstChild;
handleEle.setAttribute("class", `${opt.handleClassPrefix} ${opt.handleClassPrefix}-${handle.substr(0, handle.indexOf("H"))}`);
handleEle[IS_MINE] = true;
opt.ele.appendChild(handleEle);
} else if ("string" === handleType) {
handleEle = opt.ele.querySelector(opt[handle]);
if (!handleEle) {
return;
}
} else if (!opt[handle].addEventListener) {
return;
} else {
handleEle = opt[handle];
}
inst[handle] = new Handle(
this,
handleEle,
IS_EAST_WEST_MOVEMENT.test(handle)
? "ew"
: IS_NORTH_SOUTH_MOVEMENT.test(handle)
? "ns"
: "all"
);
this.onDestroy(inst[handle].destroy);
});
}
class Handle extends BaseClass {
constructor(resizable, handle, movement) {
super();
const { opt: { ele, minWidth, minHeight } } = PRIVATE.get(resizable);
this._emit = resizable.emit;
this._ele = ele;
this._minWidth = minWidth;
this._minHeight = minHeight;
this._handle = handle;
this._ewMovement = (movement === "all" || movement === "ew");
this._nsMovement = (movement === "all" || movement === "ns");
this._mouseupEv = null;
this._mousemoveEv = null;
this._mousedownEv = null;
this._boxWidth = 0;
this._boxHeight = 0;
this._handleX = 0;
this._handleY = 0;
this.connect();
}
connect() {
if (!this._mousedownEv) {
this._mousedownEv = domAddEventListener(this._handle, "mousedown touchstart", this.handleMouseDown, false);
}
}
disconnect() {
this.stopResizing();
if (this._mousedownEv) {
this._mousedownEv.remove();
this._mousedownEv = null;
}
}
handleMouseDown(mouseEvent) {
// FIXME: check if mouse is still down. Edge case: user tabs between windows while mousue is down.
this._boxWidth = this._ele.clientWidth;
this._boxHeight = this._ele.clientHeight;
this._handleX = getEventPointerPosition(mouseEvent, "x"); // handleEle.clientX;
this._handleY = getEventPointerPosition(mouseEvent, "y"); //handleEle.clientY;
this._mouseupEv = domAddEventListener(doc,"mouseup touchend", this.stopResizing, false);
this._mousemoveEv = domAddEventListener(doc,"mousemove touchmove", this.handleMouseMoves, false);
/**
* Resizing of element is about to start (user moused down on handle)
*
* @event Resizable#resize-start
*/
this._emit(EV_START);
}
handleMouseMoves(mouseEvent) {
const newWidth = this._boxWidth + (getEventPointerPosition(mouseEvent, "x") - this._handleX);
const newHeight = this._boxHeight + (getEventPointerPosition(mouseEvent, "y") - this._handleY);
let fireEvent = false;
if (newWidth >= this._minWidth && this._ewMovement) {
this._ele.style.width = newWidth + "px";
fireEvent = true;
}
if (newHeight >= this._minHeight && this._nsMovement) {
this._ele.style.height = newHeight + "px";
fireEvent = true;
}
if (fireEvent) {
/**
* Element was resized
*
* @event Resizable#resize
*/
this._emit(EV_RESIZE);
}
mouseEvent.stopPropagation();
mouseEvent.preventDefault();
return false;
}
stopResizing() {
let fireEvent = false;
if (this._mousemoveEv) {
this._mousemoveEv.remove();
this._mousemoveEv = null;
fireEvent = true;
}
if (this._mouseupEv) {
this._mouseupEv.remove();
this._mouseupEv = null;
fireEvent = true;
}
if (fireEvent) {
/**
* Resizing of element has ended (user released the mouse (mouseup).
*
* @event Resizable#resize-end
*/
this._emit(EV_END);
}
}
destroy() {
super.destroy();
this.disconnect();
if (this._handle[IS_MINE] && this._handle.parentNode) {
this._handle.parentNode.removeChild(this._handle);
}
}
}
function getEventPointerPosition(event, type) {
const coordinates =
IS_TOUCH.test(event.type) && event.targetTouches
? event.targetTouches.item(0)
: event;
return coordinates[`client${type.toUpperCase()}`];
}
Resizable.defaults = {
ele: null,
minWidth: 50,
minHeight: 50,
handleClassPrefix: "handle",
// FIXME: support `maxWidth` of px + keyword like `parent` or maybe just a % (implies parent)
// FIXME: support `maxHeight` of px + keyword like `parent` or maybe just a % (which would imply parent)
// FIXME: Support `restrict` which would allow the resizable to not go beyond the settings - or maybe this should be named "container"?
// drag handles
nHandle: false,
neHandle: false,
eHandle: false,
seHandle: false,
sHandle: false,
swHandle: false,
wHandle: false,
nwHandle: false
};
export { Resizable };
export default Resizable;