PopupMenu.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import {
  2. forEach,
  3. assign,
  4. find,
  5. matchPattern,
  6. isDefined
  7. } from 'min-dash';
  8. import {
  9. delegate as domDelegate,
  10. domify as domify,
  11. classes as domClasses,
  12. attr as domAttr,
  13. remove as domRemove
  14. } from 'min-dom';
  15. var DATA_REF = 'data-id';
  16. /**
  17. * A popup menu that can be used to display a list of actions anywhere in the canvas.
  18. *
  19. * @param {Object} config
  20. * @param {Boolean|Object} [config.scale={ min: 1.0, max: 1.5 }]
  21. * @param {Number} [config.scale.min]
  22. * @param {Number} [config.scale.max]
  23. * @param {EventBus} eventBus
  24. * @param {Canvas} canvas
  25. *
  26. * @class
  27. * @constructor
  28. */
  29. export default function PopupMenu(config, eventBus, canvas) {
  30. var scale = isDefined(config && config.scale) ? config.scale : {
  31. min: 1,
  32. max: 1.5
  33. };
  34. this._config = {
  35. scale: scale
  36. };
  37. this._eventBus = eventBus;
  38. this._canvas = canvas;
  39. this._providers = {};
  40. this._current = {};
  41. }
  42. PopupMenu.$inject = [
  43. 'config.popupMenu',
  44. 'eventBus',
  45. 'canvas'
  46. ];
  47. /**
  48. * Registers a popup menu provider
  49. *
  50. * @param {String} id
  51. * @param {Object} provider
  52. *
  53. * @example
  54. *
  55. * popupMenu.registerProvider('myMenuID', {
  56. * getEntries: function(element) {
  57. * return [
  58. * {
  59. * id: 'entry-1',
  60. * label: 'My Entry',
  61. * action: 'alert("I have been clicked!")'
  62. * }
  63. * ];
  64. * }
  65. * });
  66. */
  67. PopupMenu.prototype.registerProvider = function(id, provider) {
  68. this._providers[id] = provider;
  69. };
  70. /**
  71. * Determine if the popup menu has entries.
  72. *
  73. * @return {Boolean} true if empty
  74. */
  75. PopupMenu.prototype.isEmpty = function(element, providerId) {
  76. if (!element) {
  77. throw new Error('element parameter is missing');
  78. }
  79. if (!providerId) {
  80. throw new Error('providerId parameter is missing');
  81. }
  82. var provider = this._providers[providerId];
  83. var entries = provider.getEntries(element),
  84. headerEntries = provider.getHeaderEntries && provider.getHeaderEntries(element);
  85. var hasEntries = entries.length > 0,
  86. hasHeaderEntries = headerEntries && headerEntries.length > 0;
  87. return !hasEntries && !hasHeaderEntries;
  88. };
  89. /**
  90. * Create entries and open popup menu at given position
  91. *
  92. * @param {Object} element
  93. * @param {String} id provider id
  94. * @param {Object} position
  95. *
  96. * @return {Object} popup menu instance
  97. */
  98. PopupMenu.prototype.open = function(element, id, position) {
  99. var provider = this._providers[id];
  100. if (!element) {
  101. throw new Error('Element is missing');
  102. }
  103. if (!provider) {
  104. throw new Error('Provider is not registered: ' + id);
  105. }
  106. if (!position) {
  107. throw new Error('the position argument is missing');
  108. }
  109. if (this.isOpen()) {
  110. this.close();
  111. }
  112. this._emit('open');
  113. var current = this._current = {
  114. provider: provider,
  115. className: id,
  116. element: element,
  117. position: position
  118. };
  119. if (provider.getHeaderEntries) {
  120. current.headerEntries = provider.getHeaderEntries(element);
  121. }
  122. current.entries = provider.getEntries(element);
  123. current.container = this._createContainer();
  124. var headerEntries = current.headerEntries || [],
  125. entries = current.entries || [];
  126. if (headerEntries.length) {
  127. current.container.appendChild(
  128. this._createEntries(current.headerEntries, 'djs-popup-header')
  129. );
  130. }
  131. if (entries.length) {
  132. current.container.appendChild(
  133. this._createEntries(current.entries, 'djs-popup-body')
  134. );
  135. }
  136. var canvas = this._canvas,
  137. parent = canvas.getContainer();
  138. this._attachContainer(current.container, parent, position.cursor);
  139. };
  140. /**
  141. * Removes the popup menu and unbinds the event handlers.
  142. */
  143. PopupMenu.prototype.close = function() {
  144. if (!this.isOpen()) {
  145. return;
  146. }
  147. this._emit('close');
  148. this._unbindHandlers();
  149. domRemove(this._current.container);
  150. this._current.container = null;
  151. };
  152. /**
  153. * Determine if an open popup menu exist.
  154. *
  155. * @return {Boolean} true if open
  156. */
  157. PopupMenu.prototype.isOpen = function() {
  158. return !!this._current.container;
  159. };
  160. /**
  161. * Trigger an action associated with an entry.
  162. *
  163. * @param {Object} event
  164. *
  165. * @return the result of the action callback, if any
  166. */
  167. PopupMenu.prototype.trigger = function(event) {
  168. // silence other actions
  169. event.preventDefault();
  170. var element = event.delegateTarget || event.target,
  171. entryId = domAttr(element, DATA_REF);
  172. var entry = this._getEntry(entryId);
  173. if (entry.action) {
  174. return entry.action.call(null, event, entry);
  175. }
  176. };
  177. /**
  178. * Gets an entry instance (either entry or headerEntry) by id.
  179. *
  180. * @param {String} entryId
  181. *
  182. * @return {Object} entry instance
  183. */
  184. PopupMenu.prototype._getEntry = function(entryId) {
  185. var search = matchPattern({ id: entryId });
  186. var entry = find(this._current.entries, search) || find(this._current.headerEntries, search);
  187. if (!entry) {
  188. throw new Error('entry not found');
  189. }
  190. return entry;
  191. };
  192. PopupMenu.prototype._emit = function(eventName) {
  193. this._eventBus.fire('popupMenu.' + eventName);
  194. };
  195. /**
  196. * Creates the popup menu container.
  197. *
  198. * @return {Object} a DOM container
  199. */
  200. PopupMenu.prototype._createContainer = function() {
  201. var container = domify('<div class="djs-popup">'),
  202. position = this._current.position,
  203. className = this._current.className;
  204. assign(container.style, {
  205. position: 'absolute',
  206. left: position.x + 'px',
  207. top: position.y + 'px',
  208. visibility: 'hidden'
  209. });
  210. domClasses(container).add(className);
  211. return container;
  212. };
  213. /**
  214. * Attaches the container to the DOM and binds the event handlers.
  215. *
  216. * @param {Object} container
  217. * @param {Object} parent
  218. */
  219. PopupMenu.prototype._attachContainer = function(container, parent, cursor) {
  220. var self = this;
  221. // Event handler
  222. domDelegate.bind(container, '.entry' ,'click', function(event) {
  223. self.trigger(event);
  224. });
  225. this._updateScale(container);
  226. // Attach to DOM
  227. parent.appendChild(container);
  228. if (cursor) {
  229. this._assureIsInbounds(container, cursor);
  230. }
  231. // Add Handler
  232. this._bindHandlers();
  233. };
  234. /**
  235. * Updates popup style.transform with respect to the config and zoom level.
  236. *
  237. * @method _updateScale
  238. *
  239. * @param {Object} container
  240. */
  241. PopupMenu.prototype._updateScale = function(container) {
  242. var zoom = this._canvas.zoom();
  243. var scaleConfig = this._config.scale,
  244. minScale,
  245. maxScale,
  246. scale = zoom;
  247. if (scaleConfig !== true) {
  248. if (scaleConfig === false) {
  249. minScale = 1;
  250. maxScale = 1;
  251. } else {
  252. minScale = scaleConfig.min;
  253. maxScale = scaleConfig.max;
  254. }
  255. if (isDefined(minScale) && zoom < minScale) {
  256. scale = minScale;
  257. }
  258. if (isDefined(maxScale) && zoom > maxScale) {
  259. scale = maxScale;
  260. }
  261. }
  262. setTransform(container, 'scale(' + scale + ')');
  263. };
  264. /**
  265. * Make sure that the menu is always fully shown
  266. *
  267. * @method function
  268. *
  269. * @param {Object} container
  270. * @param {Position} cursor {x, y}
  271. */
  272. PopupMenu.prototype._assureIsInbounds = function(container, cursor) {
  273. var canvas = this._canvas,
  274. clientRect = canvas._container.getBoundingClientRect();
  275. var containerX = container.offsetLeft,
  276. containerY = container.offsetTop,
  277. containerWidth = container.scrollWidth,
  278. containerHeight = container.scrollHeight,
  279. overAxis = {},
  280. left, top;
  281. var cursorPosition = {
  282. x: cursor.x - clientRect.left,
  283. y: cursor.y - clientRect.top
  284. };
  285. if (containerX + containerWidth > clientRect.width) {
  286. overAxis.x = true;
  287. }
  288. if (containerY + containerHeight > clientRect.height) {
  289. overAxis.y = true;
  290. }
  291. if (overAxis.x && overAxis.y) {
  292. left = cursorPosition.x - containerWidth + 'px';
  293. top = cursorPosition.y - containerHeight + 'px';
  294. } else if (overAxis.x) {
  295. left = cursorPosition.x - containerWidth + 'px';
  296. top = cursorPosition.y + 'px';
  297. } else if (overAxis.y && cursorPosition.y < containerHeight) {
  298. left = cursorPosition.x + 'px';
  299. top = 10 + 'px';
  300. } else if (overAxis.y) {
  301. left = cursorPosition.x + 'px';
  302. top = cursorPosition.y - containerHeight + 'px';
  303. }
  304. assign(container.style, { left: left, top: top }, { visibility: 'visible', 'z-index': 1000 });
  305. };
  306. /**
  307. * Creates a list of entries and returns them as a DOM container.
  308. *
  309. * @param {Array<Object>} entries an array of entry objects
  310. * @param {String} className the class name of the entry container
  311. *
  312. * @return {Object} a DOM container
  313. */
  314. PopupMenu.prototype._createEntries = function(entries, className) {
  315. var entriesContainer = domify('<div>'),
  316. self = this;
  317. domClasses(entriesContainer).add(className);
  318. forEach(entries, function(entry) {
  319. var entryContainer = self._createEntry(entry, entriesContainer);
  320. entriesContainer.appendChild(entryContainer);
  321. });
  322. return entriesContainer;
  323. };
  324. /**
  325. * Creates a single entry and returns it as a DOM container.
  326. *
  327. * @param {Object} entry
  328. *
  329. * @return {Object} a DOM container
  330. */
  331. PopupMenu.prototype._createEntry = function(entry) {
  332. if (!entry.id) {
  333. throw new Error ('every entry must have the id property set');
  334. }
  335. var entryContainer = domify('<div>'),
  336. entryClasses = domClasses(entryContainer);
  337. entryClasses.add('entry');
  338. if (entry.className) {
  339. entry.className.split(' ').forEach(function(className) {
  340. entryClasses.add(className);
  341. });
  342. }
  343. domAttr(entryContainer, DATA_REF, entry.id);
  344. if (entry.label) {
  345. var label = domify('<span>');
  346. label.textContent = entry.label;
  347. entryContainer.appendChild(label);
  348. }
  349. if (entry.imageUrl) {
  350. entryContainer.appendChild(domify('<img src="' + entry.imageUrl + '" />'));
  351. }
  352. if (entry.active === true) {
  353. entryClasses.add('active');
  354. }
  355. if (entry.disabled === true) {
  356. entryClasses.add('disabled');
  357. }
  358. if (entry.title) {
  359. entryContainer.title = entry.title;
  360. }
  361. return entryContainer;
  362. };
  363. /**
  364. * Binds the `close` method to 'contextPad.close' & 'canvas.viewbox.changed'.
  365. */
  366. PopupMenu.prototype._bindHandlers = function() {
  367. var eventBus = this._eventBus,
  368. self = this;
  369. function close() {
  370. self.close();
  371. }
  372. eventBus.once('contextPad.close', close);
  373. eventBus.once('canvas.viewbox.changing', close);
  374. eventBus.once('commandStack.changed', close);
  375. };
  376. /**
  377. * Unbinds the `close` method to 'contextPad.close' & 'canvas.viewbox.changing'.
  378. */
  379. PopupMenu.prototype._unbindHandlers = function() {
  380. var eventBus = this._eventBus,
  381. self = this;
  382. function close() {
  383. self.close();
  384. }
  385. eventBus.off('contextPad.close', close);
  386. eventBus.off('canvas.viewbox.changed', close);
  387. eventBus.off('commandStack.changed', close);
  388. };
  389. // helpers /////////////////////////////
  390. function setTransform(element, transform) {
  391. element.style['transform-origin'] = 'top left';
  392. [ '', '-ms-', '-webkit-' ].forEach(function(prefix) {
  393. element.style[prefix + 'transform'] = transform;
  394. });
  395. }