ContextPad.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import {
  2. assign,
  3. every,
  4. forEach,
  5. isArray,
  6. isDefined,
  7. isFunction,
  8. some
  9. } from 'min-dash';
  10. import {
  11. delegate as domDelegate,
  12. event as domEvent,
  13. attr as domAttr,
  14. query as domQuery,
  15. classes as domClasses,
  16. domify as domify
  17. } from 'min-dom';
  18. import { getBBox } from '../../util/Elements';
  19. import {
  20. escapeCSS
  21. } from '../../util/EscapeUtil';
  22. var entrySelector = '.entry';
  23. var DEFAULT_PRIORITY = 1000;
  24. var CONTEXT_PAD_PADDING = 12;
  25. /**
  26. * @typedef {djs.model.Base|djs.model.Base[]} ContextPadTarget
  27. */
  28. /**
  29. * A context pad that displays element specific, contextual actions next
  30. * to a diagram element.
  31. *
  32. * @param {Canvas} canvas
  33. * @param {Object} config
  34. * @param {boolean|Object} [config.scale={ min: 1.0, max: 1.5 }]
  35. * @param {number} [config.scale.min]
  36. * @param {number} [config.scale.max]
  37. * @param {EventBus} eventBus
  38. * @param {Overlays} overlays
  39. */
  40. export default function ContextPad(canvas, config, eventBus, overlays) {
  41. this._canvas = canvas;
  42. this._eventBus = eventBus;
  43. this._overlays = overlays;
  44. var scale = isDefined(config && config.scale) ? config.scale : {
  45. min: 1,
  46. max: 1.5
  47. };
  48. this._overlaysConfig = {
  49. scale: scale
  50. };
  51. this._current = null;
  52. this._init();
  53. }
  54. ContextPad.$inject = [
  55. 'canvas',
  56. 'config.contextPad',
  57. 'eventBus',
  58. 'overlays'
  59. ];
  60. /**
  61. * Registers events needed for interaction with other components.
  62. */
  63. ContextPad.prototype._init = function() {
  64. var self = this;
  65. this._eventBus.on('selection.changed', function(event) {
  66. var selection = event.newSelection;
  67. var target = selection.length
  68. ? selection.length === 1
  69. ? selection[0]
  70. : selection
  71. : null;
  72. if (target) {
  73. self.open(target, true);
  74. } else {
  75. self.close();
  76. }
  77. });
  78. this._eventBus.on('elements.changed', function(event) {
  79. var elements = event.elements,
  80. current = self._current;
  81. if (!current) {
  82. return;
  83. }
  84. var currentTarget = current.target;
  85. var currentChanged = some(
  86. isArray(currentTarget) ? currentTarget : [ currentTarget ],
  87. function(element) {
  88. return includes(elements, element);
  89. }
  90. );
  91. // re-open if elements in current selection changed
  92. if (currentChanged) {
  93. self.open(currentTarget, true);
  94. }
  95. });
  96. };
  97. /**
  98. * Register context pad provider.
  99. *
  100. * @param {number} [priority=1000]
  101. * @param {ContextPadProvider} provider
  102. *
  103. * @example
  104. * const contextPadProvider = {
  105. * getContextPadEntries: function(element) {
  106. * return function(entries) {
  107. * return {
  108. * ...entries,
  109. * 'entry-1': {
  110. * label: 'My Entry',
  111. * action: function() { alert("I have been clicked!"); }
  112. * }
  113. * };
  114. * }
  115. * },
  116. *
  117. * getMultiElementContextPadEntries: function(elements) {
  118. * // ...
  119. * }
  120. * };
  121. *
  122. * contextPad.registerProvider(800, contextPadProvider);
  123. */
  124. ContextPad.prototype.registerProvider = function(priority, provider) {
  125. if (!provider) {
  126. provider = priority;
  127. priority = DEFAULT_PRIORITY;
  128. }
  129. this._eventBus.on('contextPad.getProviders', priority, function(event) {
  130. event.providers.push(provider);
  131. });
  132. };
  133. /**
  134. * Get context pad entries for given elements.
  135. *
  136. * @param {ContextPadTarget} target
  137. *
  138. * @return {ContextPadEntryDescriptor[]} list of entries
  139. */
  140. ContextPad.prototype.getEntries = function(target) {
  141. var providers = this._getProviders();
  142. var provideFn = isArray(target)
  143. ? 'getMultiElementContextPadEntries'
  144. : 'getContextPadEntries';
  145. var entries = {};
  146. // loop through all providers and their entries.
  147. // group entries by id so that overriding an entry is possible
  148. forEach(providers, function(provider) {
  149. if (!isFunction(provider[provideFn])) {
  150. return;
  151. }
  152. var entriesOrUpdater = provider[provideFn](target);
  153. if (isFunction(entriesOrUpdater)) {
  154. entries = entriesOrUpdater(entries);
  155. } else {
  156. forEach(entriesOrUpdater, function(entry, id) {
  157. entries[id] = entry;
  158. });
  159. }
  160. });
  161. return entries;
  162. };
  163. /**
  164. * Trigger context pad via DOM event.
  165. *
  166. * The entry to trigger is determined by the target element.
  167. *
  168. * @param {string} action
  169. * @param {Event} event
  170. * @param {boolean} [autoActivate=false]
  171. */
  172. ContextPad.prototype.trigger = function(action, event, autoActivate) {
  173. var entry,
  174. originalEvent,
  175. button = event.delegateTarget || event.target;
  176. if (!button) {
  177. return event.preventDefault();
  178. }
  179. entry = domAttr(button, 'data-action');
  180. originalEvent = event.originalEvent || event;
  181. return this.triggerEntry(entry, action, originalEvent, autoActivate);
  182. };
  183. /**
  184. * Trigger context pad entry entry.
  185. *
  186. * @param {string} entryId
  187. * @param {string} action
  188. * @param {Event} event
  189. * @param {boolean} [autoActivate=false]
  190. */
  191. ContextPad.prototype.triggerEntry = function(entryId, action, event, autoActivate) {
  192. if (!this.isShown()) {
  193. return;
  194. }
  195. var target = this._current.target,
  196. entries = this._current.entries;
  197. var entry = entries[entryId];
  198. if (!entry) {
  199. return;
  200. }
  201. var handler = entry.action;
  202. // simple action (via callback function)
  203. if (isFunction(handler)) {
  204. if (action === 'click') {
  205. return handler(event, target, autoActivate);
  206. }
  207. } else {
  208. if (handler[action]) {
  209. return handler[action](event, target, autoActivate);
  210. }
  211. }
  212. // silence other actions
  213. event.preventDefault();
  214. };
  215. /**
  216. * Open the context pad for given elements.
  217. *
  218. * @param {ContextPadTarget} target
  219. * @param {boolean} [force=false] - Force re-opening context pad.
  220. */
  221. ContextPad.prototype.open = function(target, force) {
  222. if (!force && this.isOpen(target)) {
  223. return;
  224. }
  225. this.close();
  226. this._updateAndOpen(target);
  227. };
  228. ContextPad.prototype._getProviders = function() {
  229. var event = this._eventBus.createEvent({
  230. type: 'contextPad.getProviders',
  231. providers: []
  232. });
  233. this._eventBus.fire(event);
  234. return event.providers;
  235. };
  236. /**
  237. * @param {ContextPadTarget} target
  238. */
  239. ContextPad.prototype._updateAndOpen = function(target) {
  240. var entries = this.getEntries(target),
  241. pad = this.getPad(target),
  242. html = pad.html,
  243. image;
  244. forEach(entries, function(entry, id) {
  245. var grouping = entry.group || 'default',
  246. control = domify(entry.html || '<div class="entry" draggable="true"></div>'),
  247. container;
  248. domAttr(control, 'data-action', id);
  249. container = domQuery('[data-group=' + escapeCSS(grouping) + ']', html);
  250. if (!container) {
  251. container = domify('<div class="group"></div>');
  252. domAttr(container, 'data-group', grouping);
  253. html.appendChild(container);
  254. }
  255. container.appendChild(control);
  256. if (entry.className) {
  257. addClasses(control, entry.className);
  258. }
  259. if (entry.title) {
  260. domAttr(control, 'title', entry.title);
  261. }
  262. if (entry.imageUrl) {
  263. image = domify('<img>');
  264. domAttr(image, 'src', entry.imageUrl);
  265. image.style.width = '100%';
  266. image.style.height = '100%';
  267. control.appendChild(image);
  268. }
  269. });
  270. domClasses(html).add('open');
  271. this._current = {
  272. target: target,
  273. entries: entries,
  274. pad: pad
  275. };
  276. this._eventBus.fire('contextPad.open', { current: this._current });
  277. };
  278. /**
  279. * @param {ContextPadTarget} target
  280. *
  281. * @return {Overlay}
  282. */
  283. ContextPad.prototype.getPad = function(target) {
  284. if (this.isOpen()) {
  285. return this._current.pad;
  286. }
  287. var self = this;
  288. var overlays = this._overlays;
  289. var html = domify('<div class="djs-context-pad"></div>');
  290. var position = this._getPosition(target);
  291. var overlaysConfig = assign({
  292. html: html
  293. }, this._overlaysConfig, position);
  294. domDelegate.bind(html, entrySelector, 'click', function(event) {
  295. self.trigger('click', event);
  296. });
  297. domDelegate.bind(html, entrySelector, 'dragstart', function(event) {
  298. self.trigger('dragstart', event);
  299. });
  300. // stop propagation of mouse events
  301. domEvent.bind(html, 'mousedown', function(event) {
  302. event.stopPropagation();
  303. });
  304. var activeRootElement = this._canvas.getRootElement();
  305. this._overlayId = overlays.add(activeRootElement, 'context-pad', overlaysConfig);
  306. var pad = overlays.get(this._overlayId);
  307. this._eventBus.fire('contextPad.create', {
  308. target: target,
  309. pad: pad
  310. });
  311. return pad;
  312. };
  313. /**
  314. * Close the context pad
  315. */
  316. ContextPad.prototype.close = function() {
  317. if (!this.isOpen()) {
  318. return;
  319. }
  320. this._overlays.remove(this._overlayId);
  321. this._overlayId = null;
  322. this._eventBus.fire('contextPad.close', { current: this._current });
  323. this._current = null;
  324. };
  325. /**
  326. * Check if pad is open.
  327. *
  328. * If target is provided, check if it is opened
  329. * for the given target (single or multiple elements).
  330. *
  331. * @param {ContextPadTarget} [target]
  332. * @return {boolean}
  333. */
  334. ContextPad.prototype.isOpen = function(target) {
  335. var current = this._current;
  336. if (!current) {
  337. return false;
  338. }
  339. // basic no-args is open check
  340. if (!target) {
  341. return true;
  342. }
  343. var currentTarget = current.target;
  344. // strict handling of single vs. multi-selection
  345. if (isArray(target) !== isArray(currentTarget)) {
  346. return false;
  347. }
  348. if (isArray(target)) {
  349. return (
  350. target.length === currentTarget.length &&
  351. every(target, function(element) {
  352. return includes(currentTarget, element);
  353. })
  354. );
  355. } else {
  356. return currentTarget === target;
  357. }
  358. };
  359. /**
  360. * Check if pad is open and not hidden.
  361. *
  362. * @return {boolean}
  363. */
  364. ContextPad.prototype.isShown = function() {
  365. return this.isOpen() && this._overlays.isShown();
  366. };
  367. /**
  368. * Get contex pad position.
  369. *
  370. * @param {ContextPadTarget} target
  371. * @return {Bounds}
  372. */
  373. ContextPad.prototype._getPosition = function(target) {
  374. var elements = isArray(target) ? target : [ target ];
  375. var bBox = getBBox(elements);
  376. return {
  377. position: {
  378. left: bBox.x + bBox.width + CONTEXT_PAD_PADDING,
  379. top: bBox.y - CONTEXT_PAD_PADDING / 2
  380. }
  381. };
  382. };
  383. // helpers //////////
  384. function addClasses(element, classNames) {
  385. var classes = domClasses(element);
  386. classNames = isArray(classNames) ? classNames : classNames.split(/\s+/g);
  387. classNames.forEach(function(cls) {
  388. classes.add(cls);
  389. });
  390. }
  391. /**
  392. * @param {any[]} array
  393. * @param {any} item
  394. *
  395. * @return {boolean}
  396. */
  397. function includes(array, item) {
  398. return array.indexOf(item) !== -1;
  399. }