UNPKG

ol-contextmenu

Version:
822 lines (714 loc) 22.6 kB
/*! * ol-contextmenu - v4.1.0 * https://github.com/jonataswalker/ol-contextmenu * Built: Sat Aug 15 2020 10:43:26 GMT-0300 (Brasilia Standard Time) */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('ol/control/Control')) : typeof define === 'function' && define.amd ? define(['ol/control/Control'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ContextMenu = factory(global.ol.control.Control)); }(this, (function (Control) { 'use strict'; function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var Control__default = /*#__PURE__*/_interopDefaultLegacy(Control); var namespace = 'ol-ctx-menu'; var cssVars = { namespace: namespace, container: (namespace + "-container"), separator: (namespace + "-separator"), submenu: (namespace + "-submenu"), hidden: (namespace + "-hidden"), icon: (namespace + "-icon"), zoomIn: (namespace + "-zoom-in"), zoomOut: (namespace + "-zoom-out"), unselectable: 'ol-unselectable', }; var CSS_VARS = cssVars; var EVENT_TYPE = { /** * Triggered before context menu is open. */ BEFOREOPEN: 'beforeopen', /** * Triggered when context menu is open. */ OPEN: 'open', /** * Triggered when context menu is closed. */ CLOSE: 'close', /** * Internal. Triggered when a menu entry is added. */ ADD_MENU_ENTRY: 'add-menu-entry', /** * Internal. */ CONTEXTMENU: 'contextmenu', /** * Internal. */ HOVER: 'mouseover', }; var DEFAULT_OPTIONS = { width: 150, scrollAt: 4, eventType: EVENT_TYPE.CONTEXTMENU, defaultItems: true, }; var DEFAULT_ITEMS = [ { text: 'Zoom In', classname: ((cssVars.zoomIn) + " " + (cssVars.icon)), callback: function (obj, map) { var view = map.getView(); view.animate({ zoom: +view.getZoom() + 1, duration: 700, center: obj.coordinate, }); }, }, { text: 'Zoom Out', classname: ((cssVars.zoomOut) + " " + (cssVars.icon)), callback: function (obj, map) { var view = map.getView(); view.animate({ zoom: +view.getZoom() - 1, duration: 700, center: obj.coordinate, }); }, } ]; /** * Overwrites obj1's values with obj2's and adds * obj2's if non existent in obj1 * @returns obj3 a new object based on obj1 and obj2 */ function mergeOptions(obj1, obj2) { var obj3 = {}; for (var attr1 in obj1) { obj3[attr1] = obj1[attr1]; } for (var attr2 in obj2) { obj3[attr2] = obj2[attr2]; } return obj3; } function assert(condition, message) { if ( message === void 0 ) message = 'Assertion failed'; if (!condition) { if (typeof Error !== 'undefined') { throw new Error(message); } throw message; // Fallback } } /** * Does str contain test? * @param {String} str_test * @param {String} str * @returns Boolean */ function contains(str_test, str) { return !!~str.indexOf(str_test); } function getUniqueId() { return '_' + Math.random().toString(36).substr(2, 9); } function isDefAndNotNull(val) { // Note that undefined == null. return val != null; // eslint-disable-line no-eq-null } function isNumeric(str) { return /^\d+$/.test(str); } /** * @param {Element|Array<Element>} element DOM node or array of nodes. * @param {String|Array<String>} classname Class or array of classes. * For example: 'class1 class2' or ['class1', 'class2'] * @param {Number|undefined} timeout Timeout to remove a class. */ function addClass(element, classname, timeout) { if (Array.isArray(element)) { element.forEach(function (each) { return addClass(each, classname); }); return; } var array = Array.isArray(classname) ? classname : classname.split(/\s+/); var i = array.length; while (i--) { if (!hasClass(element, array[i])) { _addClass(element, array[i], timeout); } } } /** * @param {Element|Array<Element>} element DOM node or array of nodes. * @param {String|Array<String>} classname Class or array of classes. * For example: 'class1 class2' or ['class1', 'class2'] * @param {Number|undefined} timeout Timeout to add a class. */ function removeClass(element, classname, timeout) { if (Array.isArray(element)) { element.forEach(function (each) { return removeClass(each, classname, timeout); }); return; } var array = Array.isArray(classname) ? classname : classname.split(/\s+/); var i = array.length; while (i--) { if (hasClass(element, array[i])) { _removeClass(element, array[i], timeout); } } } /** * @param {Element} element DOM node. * @param {String} classname Classname. * @return {Boolean} */ function hasClass(element, c) { // use native if available return element.classList ? element.classList.contains(c) : classRegex(c).test(element.className); } /** * Abstraction to querySelectorAll for increased * performance and greater usability * @param {String} selector * @param {Element} context (optional) * @param {Boolean} find_all (optional) * @return (find_all) {Element} : {Array} */ function find(selector, context, find_all) { if ( context === void 0 ) context = window.document; var simpleRe = /^(#?[\w-]+|\.[\w-.]+)$/, periodRe = /\./g, slice = Array.prototype.slice, matches = []; // Redirect call to the more performant function // if it's a simple selector and return an array // for easier usage if (simpleRe.test(selector)) { switch (selector[0]) { case '#': matches = [$(selector.substr(1))]; break; case '.': matches = slice.call( context.getElementsByClassName( selector.substr(1).replace(periodRe, ' ') ) ); break; default: matches = slice.call(context.getElementsByTagName(selector)); } } else { // If not a simple selector, query the DOM as usual // and return an array for easier usage matches = slice.call(context.querySelectorAll(selector)); } return find_all ? matches : matches[0]; } function $(id) { id = id[0] === '#' ? id.substr(1, id.length) : id; return document.getElementById(id); } function offset(element) { var rect = element.getBoundingClientRect(); var docEl = document.documentElement; return { left: rect.left + window.pageXOffset - docEl.clientLeft, top: rect.top + window.pageYOffset - docEl.clientTop, width: element.offsetWidth, height: element.offsetHeight, }; } function getViewportSize() { return { w: window.innerWidth || document.documentElement.clientWidth, h: window.innerHeight || document.documentElement.clientHeight, }; } function createFragment(html) { var frag = document.createDocumentFragment(), temp = document.createElement('div'); temp.innerHTML = html; while (temp.firstChild) { frag.appendChild(temp.firstChild); } return frag; } function classRegex(classname) { return new RegExp(("(^|\\s+) " + classname + " (\\s+|$)")); } function _addClass(el, klass, timeout) { // use native if available if (el.classList) { el.classList.add(klass); } else { el.className = (el.className + ' ' + klass).trim(); } if (timeout && isNumeric(timeout)) { window.setTimeout(function () { return _removeClass(el, klass); }, timeout); } } function _removeClass(el, klass, timeout) { if (el.classList) { el.classList.remove(klass); } else { el.className = el.className.replace(classRegex(klass), ' ').trim(); } if (timeout && isNumeric(timeout)) { window.setTimeout(function () { return _addClass(el, klass); }, timeout); } } /** * @class Internal */ var Internal = function Internal(base) { /** * @type {ol.control.Control} */ this.Base = base; /** * @type {ol.Map} */ this.map = undefined; /** * @type {Element} */ this.viewport = undefined; /** * @type {ol.Coordinate} */ this.coordinateClicked = undefined; /** * @type {ol.Pixel} */ this.pixelClicked = undefined; /** * @type {Number} */ this.lineHeight = 0; /** * @type {Object} */ this.items = {}; /** * @type {Boolean} */ this.opened = false; /** * @type {Object} */ this.submenu = { left: base.options.width - 15 + 'px', lastLeft: '', // string + px }; /** * @type {Function} */ this.eventHandler = this.handleEvent.bind(this); /** * @type {Function} */ this.eventMapMoveHandler = this.handleMapMoveEvent.bind(this); return this; }; Internal.prototype.init = function init (map) { this.map = map; this.viewport = map.getViewport(); this.setListeners(); this.Base.Html.createMenu(); this.lineHeight = this.getItemsLength() > 0 ? this.Base.container.offsetHeight / this.getItemsLength() : this.Base.Html.cloneAndGetLineHeight(); }; Internal.prototype.getItemsLength = function getItemsLength () { var this$1 = this; var count = 0; Object.keys(this.items).forEach(function (k) { if (this$1.items[k].submenu || this$1.items[k].separator) { return; } count++; }); return count; }; Internal.prototype.getPixelClicked = function getPixelClicked () { return this.pixelClicked; }; Internal.prototype.getCoordinateClicked = function getCoordinateClicked () { return this.coordinateClicked; }; Internal.prototype.positionContainer = function positionContainer (pixel) { var this$1 = this; var container = this.Base.container; var mapSize = this.map.getSize(); // how much (width) space left over var space_left_h = mapSize[1] - pixel[1]; // how much (height) space left over var space_left_w = mapSize[0] - pixel[0]; var menuSize = { w: container.offsetWidth, // a cheap way to recalculate container height // since offsetHeight is like cached h: Math.round(this.lineHeight * this.getItemsLength()), }; // submenus var subs = find(("li." + (CSS_VARS.submenu) + ">div"), container, true); if (space_left_w >= menuSize.w) { container.style.right = 'auto'; container.style.left = (pixel[0] + 5) + "px"; } else { container.style.left = 'auto'; container.style.right = '15px'; } // set top or bottom if (space_left_h >= menuSize.h) { container.style.bottom = 'auto'; container.style.top = (pixel[1] - 10) + "px"; } else { container.style.top = 'auto'; container.style.bottom = 0; } removeClass(container, CSS_VARS.hidden); if (subs.length) { if (space_left_w < menuSize.w * 2) { // no space (at right) for submenu // position them at left this.submenu.lastLeft = "-" + (menuSize.w) + "px"; } else { this.submenu.lastLeft = this.submenu.left; } subs.forEach(function (sub) { // is there enough space for submenu height? var viewport = getViewportSize(); var sub_offset = offset(sub); var sub_height = sub_offset.height; var sub_top = space_left_h - sub_height; if (sub_top < 0) { sub_top = sub_height - (viewport.h - sub_offset.top); sub.style.top = "-" + sub_top + "px"; } sub.style.left = this$1.submenu.lastLeft; }); } }; Internal.prototype.openMenu = function openMenu (pixel, coordinate) { this.Base.dispatchEvent({ type: EVENT_TYPE.OPEN, pixel: pixel, coordinate: coordinate, }); this.opened = true; this.positionContainer(pixel); }; Internal.prototype.closeMenu = function closeMenu () { this.opened = false; addClass(this.Base.container, CSS_VARS.hidden); this.Base.dispatchEvent({ type: EVENT_TYPE.CLOSE, }); }; Internal.prototype.setListeners = function setListeners () { this.viewport.addEventListener( this.Base.options.eventType, this.eventHandler, false ); this.map.on('movestart', this.eventMapMoveHandler); }; Internal.prototype.removeListeners = function removeListeners () { this.viewport.removeEventListener( this.Base.options.eventType, this.eventHandler, false ); this.map.un('movestart', this.eventMapMoveHandler); }; Internal.prototype.handleEvent = function handleEvent (evt) { var this_ = this; this.coordinateClicked = this.map.getEventCoordinate(evt); this.pixelClicked = this.map.getEventPixel(evt); this.Base.dispatchEvent({ type: EVENT_TYPE.BEFOREOPEN, pixel: this.pixelClicked, coordinate: this.coordinateClicked, }); if (this.Base.disabled) { return; } if (this.Base.options.eventType === EVENT_TYPE.CONTEXTMENU) { // don't be intrusive with other event types evt.stopPropagation(); evt.preventDefault(); } this.openMenu(this.pixelClicked, this.coordinateClicked); //one-time fire evt.target.addEventListener( 'pointerdown', { handleEvent: function (e) { if (this_.opened) { this_.closeMenu(); e.stopPropagation(); evt.target.removeEventListener(e.type, this, false); } }, }, false ); }; Internal.prototype.handleMapMoveEvent = function handleMapMoveEvent (evt) { this.closeMenu(); }; Internal.prototype.setItemListener = function setItemListener (li, index) { var this_ = this; if (li && typeof this.items[index].callback === 'function') { (function (callback) { li.addEventListener( 'click', function (evt) { evt.preventDefault(); var obj = { coordinate: this_.getCoordinateClicked(), data: this_.items[index].data || null, }; this_.closeMenu(); callback(obj, this_.map); }, false ); })(this.items[index].callback); } }; /** * @class Html */ var Html = function Html(base) { this.Base = base; this.Base.container = this.container = this.createContainer(true); return this; }; Html.prototype.createContainer = function createContainer (hidden) { var container = document.createElement('div'); var ul = document.createElement('ul'); var klasses = [CSS_VARS.container, CSS_VARS.unselectable]; hidden && klasses.push(CSS_VARS.hidden); container.className = klasses.join(' '); container.style.width = parseInt(this.Base.options.width, 10) + 'px'; container.appendChild(ul); return container; }; Html.prototype.createMenu = function createMenu () { var items = []; if ('items' in this.Base.options) { items = this.Base.options.defaultItems ? this.Base.options.items.concat(DEFAULT_ITEMS) : this.Base.options.items; } else if (this.Base.options.defaultItems) { items = DEFAULT_ITEMS; } // no item if (items.length === 0) { return false; } // create entries items.forEach(this.addMenuEntry, this); }; Html.prototype.addMenuEntry = function addMenuEntry (item) { var this$1 = this; if (item.items && Array.isArray(item.items)) { // submenu - only a second level item.classname = item.classname || ''; if (!contains(CSS_VARS.submenu, item.classname)) { item.classname = item.classname.length ? ' ' + CSS_VARS.submenu : CSS_VARS.submenu; } var li = this.generateHtmlAndPublish(this.container, item); var sub = this.createContainer(); sub.style.left = this.Base.Internal.submenu.lastLeft || this.Base.Internal.submenu.left; li.appendChild(sub); item.items.forEach(function (each) { this$1.generateHtmlAndPublish(sub, each, true); }); } else { this.generateHtmlAndPublish(this.container, item); } }; Html.prototype.generateHtmlAndPublish = function generateHtmlAndPublish (parent, item, submenu) { var index = getUniqueId(); var html, frag, element, separator = false; // separator if (typeof item === 'string' && item.trim() === '-') { html = "<li id=\"" + index + "\" class=\"" + (CSS_VARS.separator) + "\"><hr></li>"; frag = createFragment(html); // http://stackoverflow.com/a/13347298/4640499 element = [].slice.call(frag.childNodes, 0)[0]; parent.firstChild.appendChild(frag); // to exclude from lineHeight calculation separator = true; } else { item.classname = item.classname || ''; html = "<span>" + (item.text) + "</span>"; frag = createFragment(html); element = document.createElement('li'); if (item.icon) { if (item.classname === '') { item.classname = CSS_VARS.icon; } else if (item.classname.indexOf(CSS_VARS.icon) === -1) { item.classname += " " + (CSS_VARS.icon); } element.setAttribute('style', ("background-image:url(" + (item.icon) + ")")); } element.id = index; element.className = item.classname; element.appendChild(frag); parent.firstChild.appendChild(element); } this.Base.Internal.items[index] = { id: index, submenu: submenu || 0, separator: separator, callback: item.callback, data: item.data || null, }; this.Base.Internal.setItemListener(element, index); return element; }; Html.prototype.removeMenuEntry = function removeMenuEntry (index) { var element = find('#' + index, this.container.firstChild); element && this.container.firstChild.removeChild(element); delete this.Base.Internal.items[index]; }; Html.prototype.cloneAndGetLineHeight = function cloneAndGetLineHeight () { // for some reason I have to calculate with 2 items var cloned = this.container.cloneNode(); var frag = createFragment('<span>Foo</span>'); var frag2 = createFragment('<span>Foo</span>'); var element = document.createElement('li'); var element2 = document.createElement('li'); element.appendChild(frag); element2.appendChild(frag2); cloned.appendChild(element); cloned.appendChild(element2); this.container.parentNode.appendChild(cloned); var height = cloned.offsetHeight / 2; this.container.parentNode.removeChild(cloned); return height; }; /** * @class Base * @extends {ol.control.Control} */ var Base = /*@__PURE__*/(function (Control) { function Base(opt_options) { if ( opt_options === void 0 ) opt_options = {}; assert( typeof opt_options == 'object', '@param `opt_options` should be object type!' ); this.options = mergeOptions(DEFAULT_OPTIONS, opt_options); this.disabled = false; this.Internal = new Internal(this); this.Html = new Html(this); Control.call(this, { element: this.container }); } if ( Control ) Base.__proto__ = Control; Base.prototype = Object.create( Control && Control.prototype ); Base.prototype.constructor = Base; /** * Remove all elements from the menu. */ Base.prototype.clear = function clear () { Object.keys(this.Internal.items).forEach( this.Html.removeMenuEntry, this.Html ); }; /** * Close the menu programmatically. */ Base.prototype.close = function close () { this.Internal.closeMenu(); }; /** * Enable menu */ Base.prototype.enable = function enable () { this.disabled = false; }; /** * Disable menu */ Base.prototype.disable = function disable () { this.disabled = true; }; /** * @return {Array} Returns default items */ Base.prototype.getDefaultItems = function getDefaultItems () { return DEFAULT_ITEMS; }; /** * @return {Number} Returns how many items */ Base.prototype.countItems = function countItems () { return Object.keys(this.Internal.items).length; }; /** * Add items to the menu. This pushes each item in the provided array * to the end of the menu. * @param {Array} arr Array. */ Base.prototype.extend = function extend (arr) { assert(Array.isArray(arr), '@param `arr` should be an Array.'); arr.forEach(this.push, this); }; Base.prototype.isOpen = function isOpen () { return this.Internal.opened; }; /** * Update the menu's position. */ Base.prototype.updatePosition = function updatePosition (pixel) { assert(Array.isArray(pixel), '@param `pixel` should be an Array.'); if (this.isOpen()) { this.Internal.positionContainer(pixel); } }; /** * Remove the last item of the menu. */ Base.prototype.pop = function pop () { var keys = Object.keys(this.Internal.items); this.Html.removeMenuEntry(keys[keys.length - 1]); }; /** * Insert the provided item at the end of the menu. * @param {Object|String} item Item. */ Base.prototype.push = function push (item) { assert(isDefAndNotNull(item), '@param `item` must be informed.'); this.Html.addMenuEntry(item); }; /** * Remove the first item of the menu. */ Base.prototype.shift = function shift () { this.Html.removeMenuEntry(Object.keys(this.Internal.items)[0]); }; /** * Not supposed to be used on app. */ Base.prototype.setMap = function setMap (map) { Control.prototype.setMap.call(this, map); if (map) { // let's start since now we have the map this.Internal.init(map, this); } else { // I'm removed from the map - remove listeners this.Internal.removeListeners(); } }; return Base; }(Control__default['default'])); return Base; })));