UNPKG

@purtuga/interactions

Version:

Re-usable UI interactions.

290 lines (255 loc) 9.53 kB
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 */ @eventful() 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; } } @bound 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); } @bound 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; } @bound 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); } } @bound 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;