PopupMenu.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import {
  2. render,
  3. html
  4. } from '../../ui';
  5. import {
  6. domify,
  7. remove as domRemove,
  8. closest as domClosest,
  9. attr as domAttr
  10. } from 'min-dom';
  11. import {
  12. forEach,
  13. isFunction,
  14. omit,
  15. isDefined
  16. } from 'min-dash';
  17. import PopupMenuComponent from './PopupMenuComponent';
  18. var DATA_REF = 'data-id';
  19. var CLOSE_EVENTS = [
  20. 'contextPad.close',
  21. 'canvas.viewbox.changing',
  22. 'commandStack.changed'
  23. ];
  24. var DEFAULT_PRIORITY = 1000;
  25. /**
  26. * A popup menu that can be used to display a list of actions anywhere in the canvas.
  27. *
  28. * @param {Object} config
  29. * @param {boolean|Object} [config.scale={ min: 1.0, max: 1.5 }]
  30. * @param {number} [config.scale.min]
  31. * @param {number} [config.scale.max]
  32. * @param {EventBus} eventBus
  33. * @param {Canvas} canvas
  34. *
  35. * @class
  36. * @constructor
  37. */
  38. export default function PopupMenu(config, eventBus, canvas) {
  39. this._eventBus = eventBus;
  40. this._canvas = canvas;
  41. this._current = null;
  42. var scale = isDefined(config && config.scale) ? config.scale : {
  43. min: 1,
  44. max: 1.5
  45. };
  46. this._config = {
  47. scale: scale
  48. };
  49. eventBus.on('diagram.destroy', () => {
  50. this.close();
  51. });
  52. eventBus.on('element.changed', event => {
  53. const element = this.isOpen() && this._current.element;
  54. if (event.element === element) {
  55. this._render();
  56. }
  57. });
  58. }
  59. PopupMenu.$inject = [
  60. 'config.popupMenu',
  61. 'eventBus',
  62. 'canvas'
  63. ];
  64. PopupMenu.prototype._render = function() {
  65. const {
  66. position: _position,
  67. className,
  68. entries,
  69. headerEntries,
  70. options
  71. } = this._current;
  72. const entriesArray = Object.entries(entries).map(
  73. ([ key, value ]) => ({ id: key, ...value })
  74. );
  75. const headerEntriesArray = Object.entries(headerEntries).map(
  76. ([ key, value ]) => ({ id: key, ...value })
  77. );
  78. const position = _position && (
  79. (container) => this._ensureVisible(container, _position)
  80. );
  81. const scale = this._updateScale(this._current.container);
  82. const onClose = result => this.close(result);
  83. const onSelect = (event, entry, action) => this.trigger(event, entry, action);
  84. render(
  85. html`
  86. <${PopupMenuComponent}
  87. onClose=${ onClose }
  88. onSelect=${ onSelect }
  89. position=${ position }
  90. className=${ className }
  91. entries=${ entriesArray }
  92. headerEntries=${ headerEntriesArray }
  93. scale=${ scale }
  94. onOpened=${ this._onOpened.bind(this) }
  95. onClosed=${ this._onClosed.bind(this) }
  96. ...${{ ...options }}
  97. />
  98. `,
  99. this._current.container
  100. );
  101. };
  102. /**
  103. * Create entries and open popup menu at given position
  104. *
  105. * @param {Object} element
  106. * @param {string} id provider id
  107. * @param {Object} position
  108. *
  109. * @return {Object} popup menu instance
  110. */
  111. PopupMenu.prototype.open = function(element, providerId, position, options) {
  112. if (!element) {
  113. throw new Error('Element is missing');
  114. }
  115. if (!providerId) {
  116. throw new Error('No registered providers for: ' + providerId);
  117. }
  118. if (!position) {
  119. throw new Error('the position argument is missing');
  120. }
  121. if (this.isOpen()) {
  122. this.close();
  123. }
  124. const {
  125. entries,
  126. headerEntries
  127. } = this._getContext(element, providerId);
  128. this._current = {
  129. position,
  130. className: providerId,
  131. element,
  132. entries,
  133. headerEntries,
  134. container: this._createContainer({ provider: providerId }),
  135. options
  136. };
  137. this._emit('open');
  138. this._bindAutoClose();
  139. this._render();
  140. };
  141. PopupMenu.prototype._getContext = function(element, provider) {
  142. const providers = this._getProviders(provider);
  143. if (!providers || !providers.length) {
  144. throw new Error('No registered providers for: ' + provider);
  145. }
  146. const entries = this._getEntries(element, providers);
  147. const headerEntries = this._getHeaderEntries(element, providers);
  148. return {
  149. entries,
  150. headerEntries,
  151. empty: !(
  152. Object.keys(entries).length ||
  153. Object.keys(headerEntries).length
  154. )
  155. };
  156. };
  157. PopupMenu.prototype.close = function() {
  158. if (!this.isOpen()) {
  159. return;
  160. }
  161. this._emit('close');
  162. this.reset();
  163. this._current = null;
  164. };
  165. PopupMenu.prototype.reset = function() {
  166. const container = this._current.container;
  167. render(null, container);
  168. domRemove(container);
  169. };
  170. PopupMenu.prototype._emit = function(event, payload) {
  171. this._eventBus.fire(`popupMenu.${ event }`, payload);
  172. };
  173. PopupMenu.prototype._onOpened = function() {
  174. this._emit('opened');
  175. };
  176. PopupMenu.prototype._onClosed = function() {
  177. this._emit('closed');
  178. };
  179. PopupMenu.prototype._createContainer = function(config) {
  180. var canvas = this._canvas,
  181. parent = canvas.getContainer();
  182. const container = domify(`<div class="djs-popup-parent djs-scrollable" data-popup=${config.provider}></div>`);
  183. parent.appendChild(container);
  184. return container;
  185. };
  186. /**
  187. * Set up listener to close popup automatically on certain events.
  188. */
  189. PopupMenu.prototype._bindAutoClose = function() {
  190. this._eventBus.once(CLOSE_EVENTS, this.close, this);
  191. };
  192. /**
  193. * Remove the auto-closing listener.
  194. */
  195. PopupMenu.prototype._unbindAutoClose = function() {
  196. this._eventBus.off(CLOSE_EVENTS, this.close, this);
  197. };
  198. /**
  199. * Updates popup style.transform with respect to the config and zoom level.
  200. *
  201. * @param {Object} container
  202. */
  203. PopupMenu.prototype._updateScale = function(container) {
  204. var zoom = this._canvas.zoom();
  205. var scaleConfig = this._config.scale,
  206. minScale,
  207. maxScale,
  208. scale = zoom;
  209. if (scaleConfig !== true) {
  210. if (scaleConfig === false) {
  211. minScale = 1;
  212. maxScale = 1;
  213. } else {
  214. minScale = scaleConfig.min;
  215. maxScale = scaleConfig.max;
  216. }
  217. if (isDefined(minScale) && zoom < minScale) {
  218. scale = minScale;
  219. }
  220. if (isDefined(maxScale) && zoom > maxScale) {
  221. scale = maxScale;
  222. }
  223. }
  224. return scale;
  225. };
  226. PopupMenu.prototype._ensureVisible = function(container, position) {
  227. var documentBounds = document.documentElement.getBoundingClientRect();
  228. var containerBounds = container.getBoundingClientRect();
  229. var overAxis = {},
  230. left = position.x,
  231. top = position.y;
  232. if (position.x + containerBounds.width > documentBounds.width) {
  233. overAxis.x = true;
  234. }
  235. if (position.y + containerBounds.height > documentBounds.height) {
  236. overAxis.y = true;
  237. }
  238. if (overAxis.x && overAxis.y) {
  239. left = position.x - containerBounds.width;
  240. top = position.y - containerBounds.height;
  241. } else if (overAxis.x) {
  242. left = position.x - containerBounds.width;
  243. top = position.y;
  244. } else if (overAxis.y && position.y < containerBounds.height) {
  245. left = position.x;
  246. top = 10;
  247. } else if (overAxis.y) {
  248. left = position.x;
  249. top = position.y - containerBounds.height;
  250. }
  251. return {
  252. x: left,
  253. y: top
  254. };
  255. };
  256. PopupMenu.prototype.isEmpty = function(element, providerId) {
  257. if (!element) {
  258. throw new Error('element parameter is missing');
  259. }
  260. if (!providerId) {
  261. throw new Error('providerId parameter is missing');
  262. }
  263. const providers = this._getProviders(providerId);
  264. if (!providers || !providers.length) {
  265. return true;
  266. }
  267. return this._getContext(element, providerId).empty;
  268. };
  269. /**
  270. * Registers a popup menu provider
  271. *
  272. * @param {string} id
  273. * @param {number} [priority=1000]
  274. * @param {Object} provider
  275. *
  276. * @example
  277. * const popupMenuProvider = {
  278. * getPopupMenuEntries(element) {
  279. * return {
  280. * 'entry-1': {
  281. * label: 'My Entry',
  282. * action: function() { alert("I have been clicked!"); }
  283. * }
  284. * }
  285. * }
  286. * };
  287. *
  288. * popupMenu.registerProvider('myMenuID', popupMenuProvider);
  289. *
  290. * @example
  291. * const replacingPopupMenuProvider = {
  292. * getPopupMenuEntries(element) {
  293. * return (entries) => {
  294. * const {
  295. * someEntry,
  296. * ...remainingEntries
  297. * } = entries;
  298. *
  299. * return remainingEntries;
  300. * };
  301. * }
  302. * };
  303. *
  304. * popupMenu.registerProvider('myMenuID', replacingPopupMenuProvider);
  305. */
  306. PopupMenu.prototype.registerProvider = function(id, priority, provider) {
  307. if (!provider) {
  308. provider = priority;
  309. priority = DEFAULT_PRIORITY;
  310. }
  311. this._eventBus.on('popupMenu.getProviders.' + id, priority, function(event) {
  312. event.providers.push(provider);
  313. });
  314. };
  315. PopupMenu.prototype._getProviders = function(id) {
  316. var event = this._eventBus.createEvent({
  317. type: 'popupMenu.getProviders.' + id,
  318. providers: []
  319. });
  320. this._eventBus.fire(event);
  321. return event.providers;
  322. };
  323. PopupMenu.prototype._getEntries = function(element, providers) {
  324. var entries = {};
  325. forEach(providers, function(provider) {
  326. // handle legacy method
  327. if (!provider.getPopupMenuEntries) {
  328. forEach(provider.getEntries(element), function(entry) {
  329. var id = entry.id;
  330. if (!id) {
  331. throw new Error('every entry must have the id property set');
  332. }
  333. entries[id] = omit(entry, [ 'id' ]);
  334. });
  335. return;
  336. }
  337. var entriesOrUpdater = provider.getPopupMenuEntries(element);
  338. if (isFunction(entriesOrUpdater)) {
  339. entries = entriesOrUpdater(entries);
  340. } else {
  341. forEach(entriesOrUpdater, function(entry, id) {
  342. entries[id] = entry;
  343. });
  344. }
  345. });
  346. return entries;
  347. };
  348. PopupMenu.prototype._getHeaderEntries = function(element, providers) {
  349. var entries = {};
  350. forEach(providers, function(provider) {
  351. // handle legacy method
  352. if (!provider.getPopupMenuHeaderEntries) {
  353. if (!provider.getHeaderEntries) {
  354. return;
  355. }
  356. forEach(provider.getHeaderEntries(element), function(entry) {
  357. var id = entry.id;
  358. if (!id) {
  359. throw new Error('every entry must have the id property set');
  360. }
  361. entries[id] = omit(entry, [ 'id' ]);
  362. });
  363. return;
  364. }
  365. var entriesOrUpdater = provider.getPopupMenuHeaderEntries(element);
  366. if (isFunction(entriesOrUpdater)) {
  367. entries = entriesOrUpdater(entries);
  368. } else {
  369. forEach(entriesOrUpdater, function(entry, id) {
  370. entries[id] = entry;
  371. });
  372. }
  373. });
  374. return entries;
  375. };
  376. /**
  377. * Determine if an open popup menu exist.
  378. *
  379. * @return {boolean} true if open
  380. */
  381. PopupMenu.prototype.isOpen = function() {
  382. return !!this._current;
  383. };
  384. /**
  385. * Trigger an action associated with an entry.
  386. *
  387. * @param {Object} event
  388. * @param {Object} entry
  389. * @param {string} [action='click'] the action to trigger
  390. *
  391. * @return the result of the action callback, if any
  392. */
  393. PopupMenu.prototype.trigger = function(event, entry, action = 'click') {
  394. // silence other actions
  395. event.preventDefault();
  396. if (!entry) {
  397. let element = domClosest(event.delegateTarget || event.target, '.entry', true);
  398. let entryId = domAttr(element, DATA_REF);
  399. entry = this._getEntry(entryId);
  400. }
  401. const handler = entry.action;
  402. if (isFunction(handler)) {
  403. if (action === 'click') {
  404. return handler(event, entry);
  405. }
  406. } else {
  407. if (handler[action]) {
  408. return handler[action](event, entry);
  409. }
  410. }
  411. };
  412. /**
  413. * Gets an entry instance (either entry or headerEntry) by id.
  414. *
  415. * @param {string} entryId
  416. *
  417. * @return {Object} entry instance
  418. */
  419. PopupMenu.prototype._getEntry = function(entryId) {
  420. var entry = this._current.entries[entryId] || this._current.headerEntries[entryId];
  421. if (!entry) {
  422. throw new Error('entry not found');
  423. }
  424. return entry;
  425. };