Reference Source

src/extras/ContextMenu/ContextMenu.js

  1. import { Map } from "../../viewer/scene/utils/Map.js";
  2.  
  3. const idMap = new Map();
  4.  
  5. /**
  6. * Internal data class that represents the state of a menu or a submenu.
  7. * @private
  8. */
  9. class Menu {
  10. constructor(id) {
  11. this.id = id;
  12. this.parentItem = null; // Set to an Item when this Menu is a submenu
  13. this.groups = [];
  14. this.menuElement = null;
  15. this.shown = false;
  16. this.mouseOver = 0;
  17. }
  18. }
  19.  
  20. /**
  21. * Internal data class that represents a group of Items in a Menu.
  22. * @private
  23. */
  24. class Group {
  25. constructor() {
  26. this.items = [];
  27. }
  28. }
  29.  
  30. /**
  31. * Internal data class that represents the state of a menu item.
  32. * @private
  33. */
  34. class Item {
  35. constructor(id, getTitle, doAction, getEnabled, getShown) {
  36. this.id = id;
  37. this.getTitle = getTitle;
  38. this.doAction = doAction;
  39. this.getEnabled = getEnabled;
  40. this.getShown = getShown;
  41. this.itemElement = null;
  42. this.subMenu = null;
  43. this.enabled = true;
  44. }
  45. }
  46.  
  47. /**
  48. * @desc A customizable HTML context menu.
  49. *
  50. * [<img src="http://xeokit.io/img/docs/ContextMenu/ContextMenu.gif">](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_TreeViewPlugin_Custom)
  51. *
  52. * * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_TreeViewPlugin_Custom)]
  53. *
  54. * ## Overview
  55. *
  56. * * A pure JavaScript, lightweight context menu
  57. * * Dynamically configure menu items
  58. * * Dynamically enable or disable items
  59. * * Dynamically show or hide items
  60. * * Supports cascading sub-menus
  61. * * Configure custom style with CSS (see examples above)
  62. *
  63. * ## Usage
  64. *
  65. * In the example below we'll create a ````ContextMenu```` that pops up whenever we right-click on an {@link Entity} within
  66. * our {@link Scene}.
  67. *
  68. * First, we'll create the ````ContextMenu````, configuring it with a list of menu items.
  69. *
  70. * Each item has:
  71. *
  72. * * a ````title```` for the item,
  73. * * a ````doAction()```` callback to fire when the item's title is clicked,
  74. * * an optional ````getShown()```` callback that indicates if the item should shown in the menu or not, and
  75. * * an optional ````getEnabled()```` callback that indicates if the item should be shown enabled in the menu or not.
  76. *
  77. * <br>
  78. *
  79. * The ````getShown()```` and ````getEnabled()```` callbacks are invoked whenever the menu is shown.
  80. *
  81. * When an item's ````getShown()```` callback
  82. * returns ````true````, then the item is shown. When it returns ````false````, then the item is hidden. An item without
  83. * a ````getShown()```` callback is always shown.
  84. *
  85. * When an item's ````getEnabled()```` callback returns ````true````, then the item is enabled and clickable (as long as it's also shown). When it
  86. * returns ````false````, then the item is disabled and cannot be clicked. An item without a ````getEnabled()````
  87. * callback is always enabled and clickable.
  88. *
  89. * Note how the ````doAction()````, ````getShown()```` and ````getEnabled()```` callbacks accept a ````context````
  90. * object. That must be set on the ````ContextMenu```` before we're able to we show it. The context object can be anything. In this example,
  91. * we'll use the context object to provide the callbacks with the Entity that we right-clicked.
  92. *
  93. * We'll also initially enable the ````ContextMenu````.
  94. *
  95. * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_Custom)]
  96. *
  97. * ````javascript
  98. * const canvasContextMenu = new ContextMenu({
  99. *
  100. * enabled: true,
  101. *
  102. * items: [
  103. * [
  104. * {
  105. * title: "Hide Object",
  106. * getEnabled: (context) => {
  107. * return context.entity.visible; // Can't hide entity if already hidden
  108. * },
  109. * doAction: function (context) {
  110. * context.entity.visible = false;
  111. * }
  112. * }
  113. * ],
  114. * [
  115. * {
  116. * title: "Select Object",
  117. * getEnabled: (context) => {
  118. * return (!context.entity.selected); // Can't select an entity that's already selected
  119. * },
  120. * doAction: function (context) {
  121. * context.entity.selected = true;
  122. * }
  123. * }
  124. * ],
  125. * [
  126. * {
  127. * title: "X-Ray Object",
  128. * getEnabled: (context) => {
  129. * return (!context.entity.xrayed); // Can't X-ray an entity that's already X-rayed
  130. * },
  131. * doAction: (context) => {
  132. * context.entity.xrayed = true;
  133. * }
  134. * }
  135. * ]
  136. * ]
  137. * });
  138. * ````
  139. *
  140. * Next, we'll make the ````ContextMenu```` appear whenever we right-click on an Entity. Whenever we right-click
  141. * on the canvas, we'll attempt to pick the Entity at those mouse coordinates. If we succeed, we'll feed the
  142. * Entity into ````ContextMenu```` via the context object, then show the ````ContextMenu````.
  143. *
  144. * From there, each ````ContextMenu```` item's ````getEnabled()```` callback will be invoked (if provided), to determine if the item should
  145. * be enabled. If we click an item, its ````doAction()```` callback will be invoked with our context object.
  146. *
  147. * Remember that we must set the context on our ````ContextMenu```` before we show it, otherwise it will log an error to the console,
  148. * and ignore our attempt to show it.
  149. *
  150. * ````javascript*
  151. * viewer.scene.canvas.canvas.oncontextmenu = (e) => { // Right-clicked on the canvas
  152. *
  153. * if (!objectContextMenu.enabled) {
  154. * return;
  155. * }
  156. *
  157. * var hit = viewer.scene.pick({ // Try to pick an Entity at the coordinates
  158. * canvasPos: [e.pageX, e.pageY]
  159. * });
  160. *
  161. * if (hit) { // Picked an Entity
  162. *
  163. * objectContextMenu.context = { // Feed entity to ContextMenu
  164. * entity: hit.entity
  165. * };
  166. *
  167. * objectContextMenu.show(e.pageX, e.pageY); // Show the ContextMenu
  168. * }
  169. *
  170. * e.preventDefault();
  171. * });
  172. * ````
  173. *
  174. * Note how we only show the ````ContextMenu```` if it's enabled. We can use that mechanism to switch between multiple
  175. * ````ContextMenu```` instances depending on what we clicked.
  176. *
  177. * ## Dynamic Item Titles
  178. *
  179. * To make an item dynamically regenerate its title text whenever we show the ````ContextMenu````, provide its title with a
  180. * ````getTitle()```` callback. The callback will fire each time you show ````ContextMenu````, which will dynamically
  181. * set the item title text.
  182. *
  183. * In the example below, we'll create a simple ````ContextMenu```` that allows us to toggle the selection of an object
  184. * via its first item, which changes text depending on whether we are selecting or deselecting the object.
  185. *
  186. * [[Run an example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_dynamicItemTitles)]
  187. *
  188. * ````javascript
  189. * const canvasContextMenu = new ContextMenu({
  190. *
  191. * enabled: true,
  192. *
  193. * items: [
  194. * [
  195. * {
  196. * getTitle: (context) => {
  197. * return (!context.entity.selected) ? "Select" : "Undo Select";
  198. * },
  199. * doAction: function (context) {
  200. * context.entity.selected = !context.entity.selected;
  201. * }
  202. * },
  203. * {
  204. * title: "Clear Selection",
  205. * getEnabled: function (context) {
  206. * return (context.viewer.scene.numSelectedObjects > 0);
  207. * },
  208. * doAction: function (context) {
  209. * context.viewer.scene.setObjectsSelected(context.viewer.scene.selectedObjectIds, false);
  210. * }
  211. * }
  212. * ]
  213. * ]
  214. * });
  215. * ````
  216. *
  217. * ## Sub-menus
  218. *
  219. * Each menu item can optionally have a sub-menu, which will appear when we hover over the item.
  220. *
  221. * In the example below, we'll create a much simpler ````ContextMenu```` that has only one item, called "Effects", which
  222. * will open a cascading sub-menu whenever we hover over that item.
  223. *
  224. * Note that our "Effects" item has no ````doAction```` callback, because an item with a sub-menu performs no
  225. * action of its own.
  226. *
  227. * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_subMenus)]
  228. *
  229. * ````javascript
  230. * const canvasContextMenu = new ContextMenu({
  231. * items: [ // Top level items
  232. * [
  233. * {
  234. * getTitle: (context) => {
  235. * return "Effects";
  236. * },
  237. *
  238. * items: [ // Sub-menu
  239. * [
  240. * {
  241. * getTitle: (context) => {
  242. * return (!context.entity.visible) ? "Show" : "Hide";
  243. * },
  244. * doAction: function (context) {
  245. * context.entity.visible = !context.entity.visible;
  246. * }
  247. * },
  248. * {
  249. * getTitle: (context) => {
  250. * return (!context.entity.selected) ? "Select" : "Undo Select";
  251. * },
  252. * doAction: function (context) {
  253. * context.entity.selected = !context.entity.selected;
  254. * }
  255. * },
  256. * {
  257. * getTitle: (context) => {
  258. * return (!context.entity.highlighted) ? "Highlight" : "Undo Highlight";
  259. * },
  260. * doAction: function (context) {
  261. * context.entity.highlighted = !context.entity.highlighted;
  262. * }
  263. * }
  264. * ]
  265. * ]
  266. * }
  267. * ]
  268. * ]
  269. * });
  270. * ````
  271. */
  272. class ContextMenu {
  273.  
  274. /**
  275. * Creates a ````ContextMenu````.
  276. *
  277. * The ````ContextMenu```` will be initially hidden.
  278. *
  279. * @param {Object} [cfg] ````ContextMenu```` configuration.
  280. * @param {Object} [cfg.items] The context menu items. These can also be dynamically set on {@link ContextMenu#items}. See the class documentation for an example.
  281. * @param {Object} [cfg.context] The context, which is passed into the item callbacks. This can also be dynamically set on {@link ContextMenu#context}. This must be set before calling {@link ContextMenu#show}.
  282. * @param {Boolean} [cfg.enabled=true] Whether this ````ContextMenu```` is initially enabled. {@link ContextMenu#show} does nothing while this is ````false````.
  283. * @param {Boolean} [cfg.hideOnMouseDown=true] Whether this ````ContextMenu```` automatically hides whenever we mouse-down or tap anywhere in the page.
  284. * @param {Boolean} [cfg.hideOnAction=true] Whether this ````ContextMenu```` automatically hides after we select a menu item. Se false if we want the menu to remain shown and show any updates to its item titles, after we've selected an item.
  285. */
  286. constructor(cfg = {}) {
  287.  
  288. this._id = idMap.addItem();
  289. this._context = null;
  290. this._enabled = false; // True when the ContextMenu is enabled
  291. this._itemsCfg = []; // Items as given as configs
  292. this._rootMenu = null; // The root Menu in the tree
  293. this._menuList = []; // List of Menus
  294. this._menuMap = {}; // Menus mapped to their IDs
  295. this._itemList = []; // List of Items
  296. this._itemMap = {}; // Items mapped to their IDs
  297. this._shown = false; // True when the ContextMenu is visible
  298. this._nextId = 0;
  299.  
  300. /**
  301. * Subscriptions to events fired at this ContextMenu.
  302. * @private
  303. */
  304. this._eventSubs = {};
  305.  
  306. if (cfg.hideOnMouseDown !== false) {
  307. document.addEventListener("mousedown", (event) => {
  308. if (!event.target.classList.contains("xeokit-context-menu-item")) {
  309. this.hide();
  310. }
  311. });
  312. document.addEventListener("touchstart", this._canvasTouchStartHandler = (event) => {
  313. if (!event.target.classList.contains("xeokit-context-menu-item")) {
  314. this.hide();
  315. }
  316. });
  317. }
  318.  
  319. if (cfg.items) {
  320. this.items = cfg.items;
  321. }
  322.  
  323. this._hideOnAction = (cfg.hideOnAction !== false);
  324.  
  325. this.context = cfg.context;
  326. this.enabled = cfg.enabled !== false;
  327. this.hide();
  328. }
  329.  
  330.  
  331. /**
  332. Subscribes to an event fired at this ````ContextMenu````.
  333.  
  334. @param {String} event The event
  335. @param {Function} callback Callback fired on the event
  336. */
  337. on(event, callback) {
  338. let subs = this._eventSubs[event];
  339. if (!subs) {
  340. subs = [];
  341. this._eventSubs[event] = subs;
  342. }
  343. subs.push(callback);
  344. }
  345.  
  346. /**
  347. Fires an event at this ````ContextMenu````.
  348.  
  349. @param {String} event The event type name
  350. @param {Object} value The event parameters
  351. */
  352. fire(event, value) {
  353. const subs = this._eventSubs[event];
  354. if (subs) {
  355. for (let i = 0, len = subs.length; i < len; i++) {
  356. subs[i](value);
  357. }
  358. }
  359. }
  360.  
  361. /**
  362. * Sets the ````ContextMenu```` items.
  363. *
  364. * These can be updated dynamically at any time.
  365. *
  366. * See class documentation for an example.
  367. *
  368. * @type {Object[]}
  369. */
  370. set items(itemsCfg) {
  371. this._clear();
  372. this._itemsCfg = itemsCfg || [];
  373. this._parseItems(itemsCfg);
  374. this._createUI();
  375. }
  376.  
  377. /**
  378. * Gets the ````ContextMenu```` items.
  379. *
  380. * @type {Object[]}
  381. */
  382. get items() {
  383. return this._itemsCfg;
  384. }
  385.  
  386. /**
  387. * Sets whether this ````ContextMenu```` is enabled.
  388. *
  389. * Hides the menu when disabling.
  390. *
  391. * @type {Boolean}
  392. */
  393. set enabled(enabled) {
  394. enabled = (!!enabled);
  395. if (enabled === this._enabled) {
  396. return;
  397. }
  398. this._enabled = enabled;
  399. if (!this._enabled) {
  400. this.hide();
  401. }
  402. }
  403.  
  404. /**
  405. * Gets whether this ````ContextMenu```` is enabled.
  406. *
  407. * {@link ContextMenu#show} does nothing while this is ````false````.
  408. *
  409. * @type {Boolean}
  410. */
  411. get enabled() {
  412. return this._enabled;
  413. }
  414.  
  415. /**
  416. * Sets the ````ContextMenu```` context.
  417. *
  418. * The context can be any object that you need to be provides to the callbacks configured on {@link ContextMenu#items}.
  419. *
  420. * This must be set before calling {@link ContextMenu#show}.
  421. *
  422. * @type {Object}
  423. */
  424. set context(context) {
  425. this._context = context;
  426. }
  427.  
  428. /**
  429. * Gets the ````ContextMenu```` context.
  430. *
  431. * @type {Object}
  432. */
  433. get context() {
  434. return this._context;
  435. }
  436.  
  437. /**
  438. * Shows this ````ContextMenu```` at the given page coordinates.
  439. *
  440. * Does nothing when {@link ContextMenu#enabled} is ````false````.
  441. *
  442. * Logs error to console and does nothing if {@link ContextMenu#context} has not been set.
  443. *
  444. * Fires a "shown" event when shown.
  445. *
  446. * @param {Number} pageX Page X-coordinate.
  447. * @param {Number} pageY Page Y-coordinate.
  448. */
  449. show(pageX, pageY) {
  450. if (!this._context) {
  451. console.error("ContextMenu cannot be shown without a context - set context first");
  452. return;
  453. }
  454. if (!this._enabled) {
  455. return;
  456. }
  457. if (this._shown) {
  458. return;
  459. }
  460. this._hideAllMenus();
  461. this._updateItemsTitles();
  462. this._updateItemsEnabledStatus();
  463. this._showMenu(this._rootMenu.id, pageX, pageY);
  464. this._updateSubMenuInfo();
  465. this._shown = true;
  466. this.fire("shown", {});
  467. }
  468.  
  469. /**
  470. * Gets whether this ````ContextMenu```` is currently shown or not.
  471. *
  472. * @returns {Boolean} Whether this ````ContextMenu```` is shown.
  473. */
  474. get shown() {
  475. return this._shown;
  476. }
  477.  
  478. /**
  479. * Hides this ````ContextMenu````.
  480. *
  481. * Fires a "hidden" event when hidden.
  482. */
  483. hide() {
  484. if (!this._enabled) {
  485. return;
  486. }
  487. if (!this._shown) {
  488. return;
  489. }
  490. this._hideAllMenus();
  491. this._shown = false;
  492. this.fire("hidden", {});
  493. }
  494.  
  495. /**
  496. * Destroys this ````ContextMenu````.
  497. */
  498. destroy() {
  499. this._context = null;
  500. this._clear();
  501. if (this._id !== null) {
  502. idMap.removeItem(this._id);
  503. this._id = null;
  504. }
  505. }
  506.  
  507. _clear() { // Destroys DOM elements, clears menu data
  508. for (let i = 0, len = this._menuList.length; i < len; i++) {
  509. const menu = this._menuList[i];
  510. const menuElement = menu.menuElement;
  511. menuElement.parentElement.removeChild(menuElement);
  512. }
  513. this._itemsCfg = [];
  514. this._rootMenu = null;
  515. this._menuList = [];
  516. this._menuMap = {};
  517. this._itemList = [];
  518. this._itemMap = {};
  519. }
  520.  
  521. _parseItems(itemsCfg) { // Parses "items" config into menu data
  522.  
  523. const visitItems = (itemsCfg) => {
  524.  
  525. const menuId = this._getNextId();
  526. const menu = new Menu(menuId);
  527.  
  528. for (let i = 0, len = itemsCfg.length; i < len; i++) {
  529.  
  530. const itemsGroupCfg = itemsCfg[i];
  531.  
  532. const group = new Group();
  533.  
  534. menu.groups.push(group);
  535.  
  536. for (let j = 0, lenj = itemsGroupCfg.length; j < lenj; j++) {
  537.  
  538. const itemCfg = itemsGroupCfg[j];
  539. const subItemsCfg = itemCfg.items;
  540. const hasSubItems = (subItemsCfg && (subItemsCfg.length > 0));
  541. const itemId = this._getNextId();
  542.  
  543. const getTitle = itemCfg.getTitle || (() => {
  544. return (itemCfg.title || "");
  545. });
  546.  
  547. const doAction = itemCfg.doAction || itemCfg.callback || (() => {
  548. });
  549.  
  550. const getEnabled = itemCfg.getEnabled || (() => {
  551. return true;
  552. });
  553.  
  554. const getShown = itemCfg.getShown || (() => {
  555. return true;
  556. });
  557.  
  558. const item = new Item(itemId, getTitle, doAction, getEnabled, getShown);
  559.  
  560. item.parentMenu = menu;
  561.  
  562. group.items.push(item);
  563.  
  564. if (hasSubItems) {
  565. const subMenu = visitItems(subItemsCfg);
  566. item.subMenu = subMenu;
  567. subMenu.parentItem = item;
  568. }
  569.  
  570. this._itemList.push(item);
  571. this._itemMap[item.id] = item;
  572. }
  573. }
  574.  
  575. this._menuList.push(menu);
  576. this._menuMap[menu.id] = menu;
  577.  
  578. return menu;
  579. };
  580.  
  581. this._rootMenu = visitItems(itemsCfg);
  582. }
  583.  
  584. _getNextId() { // Returns a unique ID
  585. return "ContextMenu_" + this._id + "_" + this._nextId++; // Start ID with alpha chars to make a valid DOM element selector
  586. }
  587.  
  588. _createUI() { // Builds DOM elements for the entire menu tree
  589.  
  590. const visitMenu = (menu) => {
  591.  
  592. this._createMenuUI(menu);
  593.  
  594. const groups = menu.groups;
  595. for (let i = 0, len = groups.length; i < len; i++) {
  596. const group = groups[i];
  597. const groupItems = group.items;
  598. for (let j = 0, lenj = groupItems.length; j < lenj; j++) {
  599. const item = groupItems[j];
  600. const subMenu = item.subMenu;
  601. if (subMenu) {
  602. visitMenu(subMenu);
  603. }
  604. }
  605. }
  606. };
  607.  
  608. visitMenu(this._rootMenu);
  609. }
  610.  
  611. _createMenuUI(menu) { // Builds DOM elements for a menu
  612.  
  613. const groups = menu.groups;
  614. const html = [];
  615.  
  616. html.push('<div class="xeokit-context-menu ' + menu.id + '" style="z-index:300000; position: absolute;">');
  617.  
  618. html.push('<ul>');
  619.  
  620. if (groups) {
  621.  
  622. for (let i = 0, len = groups.length; i < len; i++) {
  623.  
  624. const group = groups[i];
  625. const groupIdx = i;
  626. const groupLen = len;
  627. const groupItems = group.items;
  628.  
  629. if (groupItems) {
  630.  
  631. for (let j = 0, lenj = groupItems.length; j < lenj; j++) {
  632.  
  633. const item = groupItems[j];
  634. const itemSubMenu = item.subMenu;
  635. const actionTitle = item.title || "";
  636.  
  637. if (itemSubMenu) {
  638.  
  639. html.push(
  640. '<li id="' + item.id + '" class="xeokit-context-menu-item xeokit-context-menu-submenu">' +
  641. actionTitle +
  642. '</li>');
  643. if (!((groupIdx === groupLen - 1) || (j < lenj - 1))) {
  644. html.push(
  645. '<li id="' + item.id + '" class="xeokit-context-menu-item-separator"></li>'
  646. );
  647. }
  648.  
  649. } else {
  650.  
  651. html.push(
  652. '<li id="' + item.id + '" class="xeokit-context-menu-item">' +
  653. actionTitle +
  654. '</li>');
  655. if (!((groupIdx === groupLen - 1) || (j < lenj - 1))) {
  656. html.push(
  657. '<li id="' + item.id + '" class="xeokit-context-menu-item-separator"></li>'
  658. );
  659. }
  660. }
  661. }
  662. }
  663. }
  664. }
  665.  
  666. html.push('</ul>');
  667. html.push('</div>');
  668.  
  669. const htmlString = html.join("");
  670.  
  671. document.body.insertAdjacentHTML('beforeend', htmlString);
  672.  
  673. const menuElement = document.querySelector("." + menu.id);
  674.  
  675. menu.menuElement = menuElement;
  676.  
  677. menuElement.style["border-radius"] = 4 + "px";
  678. menuElement.style.display = 'none';
  679. menuElement.style["z-index"] = 300000;
  680. menuElement.style.background = "white";
  681. menuElement.style.border = "1px solid black";
  682. menuElement.style["box-shadow"] = "0 4px 5px 0 gray";
  683. menuElement.oncontextmenu = (e) => {
  684. e.preventDefault();
  685. };
  686.  
  687. // Bind event handlers
  688.  
  689. const self = this;
  690.  
  691. let lastSubMenu = null;
  692.  
  693. if (groups) {
  694.  
  695. for (let i = 0, len = groups.length; i < len; i++) {
  696.  
  697. const group = groups[i];
  698. const groupItems = group.items;
  699.  
  700. if (groupItems) {
  701.  
  702. for (let j = 0, lenj = groupItems.length; j < lenj; j++) {
  703.  
  704. const item = groupItems[j];
  705. const itemSubMenu = item.subMenu;
  706.  
  707. item.itemElement = document.getElementById(item.id);
  708.  
  709. if (!item.itemElement) {
  710. console.error("ContextMenu item element not found: " + item.id);
  711. continue;
  712. }
  713.  
  714. item.itemElement.addEventListener("mouseenter", (event) => {
  715. event.preventDefault();
  716.  
  717. const subMenu = item.subMenu;
  718. if (!subMenu) {
  719. if (lastSubMenu) {
  720. self._hideMenu(lastSubMenu.id);
  721. lastSubMenu = null;
  722. }
  723. return;
  724. }
  725. if (lastSubMenu && (lastSubMenu.id !== subMenu.id)) {
  726. self._hideMenu(lastSubMenu.id);
  727. lastSubMenu = null;
  728. }
  729.  
  730. if (item.enabled === false) {
  731. return;
  732. }
  733.  
  734. const itemElement = item.itemElement;
  735. const subMenuElement = subMenu.menuElement;
  736.  
  737. const itemRect = itemElement.getBoundingClientRect();
  738. const menuRect = subMenuElement.getBoundingClientRect();
  739.  
  740. const subMenuWidth = 200; // TODO
  741. const showOnLeft = ((itemRect.right + subMenuWidth) > window.innerWidth);
  742.  
  743. if (showOnLeft) {
  744. self._showMenu(subMenu.id, itemRect.left - subMenuWidth, itemRect.top - 1);
  745. } else {
  746. self._showMenu(subMenu.id, itemRect.right - 5, itemRect.top - 1);
  747. }
  748.  
  749. lastSubMenu = subMenu;
  750. });
  751.  
  752. if (!itemSubMenu) {
  753.  
  754. // Item without sub-menu
  755. // clicking item fires the item's action callback
  756.  
  757. item.itemElement.addEventListener("click", (event) => {
  758. event.preventDefault();
  759. if (!self._context) {
  760. return;
  761. }
  762. if (item.enabled === false) {
  763. return;
  764. }
  765. if (item.doAction) {
  766. item.doAction(self._context);
  767. }
  768. if (this._hideOnAction) {
  769. self.hide();
  770. } else {
  771. self._updateItemsTitles();
  772. self._updateItemsEnabledStatus();
  773. }
  774. });
  775. item.itemElement.addEventListener("mouseup", (event) => {
  776. if (event.which !== 3) {
  777. return;
  778. }
  779. event.preventDefault();
  780. if (!self._context) {
  781. return;
  782. }
  783. if (item.enabled === false) {
  784. return;
  785. }
  786. if (item.doAction) {
  787. item.doAction(self._context);
  788. }
  789. if (this._hideOnAction) {
  790. self.hide();
  791. } else {
  792. self._updateItemsTitles();
  793. self._updateItemsEnabledStatus();
  794. }
  795. });
  796. item.itemElement.addEventListener("mouseenter", (event) => {
  797. event.preventDefault();
  798. if (item.enabled === false) {
  799. return;
  800. }
  801. if (item.doHover) {
  802. item.doHover(self._context);
  803. }
  804. });
  805.  
  806. }
  807. }
  808. }
  809. }
  810. }
  811. }
  812.  
  813. _updateItemsTitles() { // Dynamically updates the title of each Item to the result of Item#getTitle()
  814. if (!this._context) {
  815. return;
  816. }
  817. for (let i = 0, len = this._itemList.length; i < len; i++) {
  818. const item = this._itemList[i];
  819. const itemElement = item.itemElement;
  820. if (!itemElement) {
  821. continue;
  822. }
  823. const getShown = item.getShown;
  824. if (!getShown || !getShown(this._context)) {
  825. continue;
  826. }
  827. const title = item.getTitle(this._context);
  828. if (item.subMenu) {
  829. itemElement.innerText = title;
  830. } else {
  831. itemElement.innerText = title;
  832. }
  833. }
  834. }
  835.  
  836. _updateItemsEnabledStatus() { // Enables or disables each Item, depending on the result of Item#getEnabled()
  837. if (!this._context) {
  838. return;
  839. }
  840. for (let i = 0, len = this._itemList.length; i < len; i++) {
  841. const item = this._itemList[i];
  842. const itemElement = item.itemElement;
  843. if (!itemElement) {
  844. continue;
  845. }
  846. const getEnabled = item.getEnabled;
  847. if (!getEnabled) {
  848. continue;
  849. }
  850. const getShown = item.getShown;
  851. if (!getShown) {
  852. continue;
  853. }
  854. const shown = getShown(this._context);
  855. item.shown = shown;
  856. if (!shown) {
  857. itemElement.style.display = "none";
  858. continue;
  859. } else {
  860. itemElement.style.display = "";
  861.  
  862. }
  863. const enabled = getEnabled(this._context);
  864. item.enabled = enabled;
  865. if (!enabled) {
  866. itemElement.classList.add("disabled");
  867. } else {
  868. itemElement.classList.remove("disabled");
  869. }
  870. }
  871. }
  872.  
  873. _updateSubMenuInfo() {
  874. if (!this._context) return;
  875. let itemElement, itemRect, subMenuElement, initialStyles, showOnLeft, subMenuWidth;
  876. this._itemList.forEach((item) => {
  877. if (item.subMenu) {
  878. itemElement = item.itemElement;
  879. itemRect = itemElement.getBoundingClientRect();
  880. subMenuElement = item.subMenu.menuElement;
  881. initialStyles = {
  882. visibility: subMenuElement.style.visibility,
  883. display: subMenuElement.style.display,
  884. }
  885. subMenuElement.style.display = "block";
  886. subMenuElement.style.visibility = "hidden";
  887. subMenuWidth = item.subMenu.menuElement.getBoundingClientRect().width;
  888. subMenuElement.style.visibility = initialStyles.visibility;
  889. subMenuElement.style.display = initialStyles.display;
  890. showOnLeft = ((itemRect.right + subMenuWidth) > window.innerWidth);
  891. itemElement.setAttribute("data-submenuposition", showOnLeft ? "left" : "right");
  892. }
  893. })
  894. }
  895.  
  896. _showMenu(menuId, pageX, pageY) { // Shows the given menu, at the specified page coordinates
  897. const menu = this._menuMap[menuId];
  898. if (!menu) {
  899. console.error("Menu not found: " + menuId);
  900. return;
  901. }
  902. if (menu.shown) {
  903. return;
  904. }
  905. const menuElement = menu.menuElement;
  906. if (menuElement) {
  907. this._showMenuElement(menuElement, pageX, pageY);
  908. menu.shown = true;
  909. }
  910. }
  911.  
  912. _hideMenu(menuId) { // Hides the given menu
  913. const menu = this._menuMap[menuId];
  914. if (!menu) {
  915. console.error("Menu not found: " + menuId);
  916. return;
  917. }
  918. if (!menu.shown) {
  919. return;
  920. }
  921. const menuElement = menu.menuElement;
  922. if (menuElement) {
  923. this._hideMenuElement(menuElement);
  924. menu.shown = false;
  925. }
  926. }
  927.  
  928. _hideAllMenus() {
  929. for (let i = 0, len = this._menuList.length; i < len; i++) {
  930. const menu = this._menuList[i];
  931. this._hideMenu(menu.id);
  932. }
  933. }
  934.  
  935. _showMenuElement(menuElement, pageX, pageY) { // Shows the given menu element, at the specified page coordinates
  936. menuElement.style.display = 'block';
  937. const menuHeight = menuElement.offsetHeight;
  938. const menuWidth = menuElement.offsetWidth;
  939. if ((pageY + menuHeight) > window.innerHeight) {
  940. pageY = window.innerHeight - menuHeight;
  941. }
  942. if ((pageX + menuWidth) > window.innerWidth) {
  943. pageX = window.innerWidth - menuWidth;
  944. }
  945. menuElement.style.left = pageX + 'px';
  946. menuElement.style.top = pageY + 'px';
  947. }
  948.  
  949. _hideMenuElement(menuElement) {
  950. menuElement.style.display = 'none';
  951. }
  952. }
  953.  
  954. export { ContextMenu };