UNPKG

@xeokit/xeokit-sdk

Version:

3D BIM IFC Viewer SDK for AEC engineering applications. Open Source JavaScript Toolkit based on pure WebGL for top performance, real-world coordinates and full double precision

1,509 lines (1,332 loc) 6.39 MB
/** * xeokit-sdk v2.6.78 * Commit: 17c76751873b26ec38b03ae632f1d2003dc7a98a * Built: 2025-06-18T11:33:48.212Z */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); /** @private */ class Map$1 { constructor(items, baseId) { this.items = items || []; this._lastUniqueId = (baseId || 0) + 1; } /** * Usage: * * id = myMap.addItem("foo") // ID internally generated * id = myMap.addItem("foo", "bar") // ID is "foo" */ addItem() { let item; if (arguments.length === 2) { const id = arguments[0]; item = arguments[1]; if (this.items[id]) { // Won't happen if given ID is string throw "ID clash: '" + id + "'"; } this.items[id] = item; return id; } else { item = arguments[0] || {}; while (true) { const findId = this._lastUniqueId++; if (!this.items[findId]) { this.items[findId] = item; return findId; } } } } removeItem(id) { const item = this.items[id]; delete this.items[id]; return item; } } const idMap = new Map$1(); /** * Internal data class that represents the state of a menu or a submenu. * @private */ class Menu { constructor(id) { this.id = id; this.parentItem = null; // Set to an Item when this Menu is a submenu this.groups = []; this.menuElement = null; this.shown = false; this.mouseOver = 0; } } /** * Internal data class that represents a group of Items in a Menu. * @private */ class Group { constructor() { this.items = []; } } /** * Internal data class that represents the state of a menu item. * @private */ class Item { constructor(id, getTitle, doAction, getEnabled, getShown) { this.id = id; this.getTitle = getTitle; this.doAction = doAction; this.getEnabled = getEnabled; this.getShown = getShown; this.itemElement = null; this.subMenu = null; this.enabled = true; } } /** * @desc A customizable HTML context menu. * * [<img src="http://xeokit.io/img/docs/ContextMenu/ContextMenu.gif">](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_TreeViewPlugin_Custom) * * * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_TreeViewPlugin_Custom)] * * ## Overview * * * A pure JavaScript, lightweight context menu * * Dynamically configure menu items * * Dynamically enable or disable items * * Dynamically show or hide items * * Supports cascading sub-menus * * Configure custom style with CSS (see examples above) * * ## Usage * * In the example below we'll create a ````ContextMenu```` that pops up whenever we right-click on an {@link Entity} within * our {@link Scene}. * * First, we'll create the ````ContextMenu````, configuring it with a list of menu items. * * Each item has: * * * a ````title```` for the item, * * a ````doAction()```` callback to fire when the item's title is clicked, * * an optional ````getShown()```` callback that indicates if the item should shown in the menu or not, and * * an optional ````getEnabled()```` callback that indicates if the item should be shown enabled in the menu or not. * * <br> * * The ````getShown()```` and ````getEnabled()```` callbacks are invoked whenever the menu is shown. * * When an item's ````getShown()```` callback * returns ````true````, then the item is shown. When it returns ````false````, then the item is hidden. An item without * a ````getShown()```` callback is always shown. * * When an item's ````getEnabled()```` callback returns ````true````, then the item is enabled and clickable (as long as it's also shown). When it * returns ````false````, then the item is disabled and cannot be clicked. An item without a ````getEnabled()```` * callback is always enabled and clickable. * * Note how the ````doAction()````, ````getShown()```` and ````getEnabled()```` callbacks accept a ````context```` * 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, * we'll use the context object to provide the callbacks with the Entity that we right-clicked. * * We'll also initially enable the ````ContextMenu````. * * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_Canvas_Custom)] * * ````javascript * const canvasContextMenu = new ContextMenu({ * * enabled: true, * * items: [ * [ * { * title: "Hide Object", * getEnabled: (context) => { * return context.entity.visible; // Can't hide entity if already hidden * }, * doAction: function (context) { * context.entity.visible = false; * } * } * ], * [ * { * title: "Select Object", * getEnabled: (context) => { * return (!context.entity.selected); // Can't select an entity that's already selected * }, * doAction: function (context) { * context.entity.selected = true; * } * } * ], * [ * { * title: "X-Ray Object", * getEnabled: (context) => { * return (!context.entity.xrayed); // Can't X-ray an entity that's already X-rayed * }, * doAction: (context) => { * context.entity.xrayed = true; * } * } * ] * ] * }); * ```` * * Next, we'll make the ````ContextMenu```` appear whenever we right-click on an Entity. Whenever we right-click * on the canvas, we'll attempt to pick the Entity at those mouse coordinates. If we succeed, we'll feed the * Entity into ````ContextMenu```` via the context object, then show the ````ContextMenu````. * * From there, each ````ContextMenu```` item's ````getEnabled()```` callback will be invoked (if provided), to determine if the item should * be enabled. If we click an item, its ````doAction()```` callback will be invoked with our context object. * * Remember that we must set the context on our ````ContextMenu```` before we show it, otherwise it will log an error to the console, * and ignore our attempt to show it. * * ````javascript* * viewer.scene.canvas.canvas.oncontextmenu = (e) => { // Right-clicked on the canvas * * if (!objectContextMenu.enabled) { * return; * } * * var hit = viewer.scene.pick({ // Try to pick an Entity at the coordinates * canvasPos: [e.pageX, e.pageY] * }); * * if (hit) { // Picked an Entity * * objectContextMenu.context = { // Feed entity to ContextMenu * entity: hit.entity * }; * * objectContextMenu.show(e.pageX, e.pageY); // Show the ContextMenu * } * * e.preventDefault(); * }); * ```` * * Note how we only show the ````ContextMenu```` if it's enabled. We can use that mechanism to switch between multiple * ````ContextMenu```` instances depending on what we clicked. * * ## Dynamic Item Titles * * To make an item dynamically regenerate its title text whenever we show the ````ContextMenu````, provide its title with a * ````getTitle()```` callback. The callback will fire each time you show ````ContextMenu````, which will dynamically * set the item title text. * * In the example below, we'll create a simple ````ContextMenu```` that allows us to toggle the selection of an object * via its first item, which changes text depending on whether we are selecting or deselecting the object. * * [[Run an example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_dynamicItemTitles)] * * ````javascript * const canvasContextMenu = new ContextMenu({ * * enabled: true, * * items: [ * [ * { * getTitle: (context) => { * return (!context.entity.selected) ? "Select" : "Undo Select"; * }, * doAction: function (context) { * context.entity.selected = !context.entity.selected; * } * }, * { * title: "Clear Selection", * getEnabled: function (context) { * return (context.viewer.scene.numSelectedObjects > 0); * }, * doAction: function (context) { * context.viewer.scene.setObjectsSelected(context.viewer.scene.selectedObjectIds, false); * } * } * ] * ] * }); * ```` * * ## Sub-menus * * Each menu item can optionally have a sub-menu, which will appear when we hover over the item. * * In the example below, we'll create a much simpler ````ContextMenu```` that has only one item, called "Effects", which * will open a cascading sub-menu whenever we hover over that item. * * Note that our "Effects" item has no ````doAction```` callback, because an item with a sub-menu performs no * action of its own. * * [[Run this example](https://xeokit.github.io/xeokit-sdk/examples/index.html#ContextMenu_subMenus)] * * ````javascript * const canvasContextMenu = new ContextMenu({ * items: [ // Top level items * [ * { * getTitle: (context) => { * return "Effects"; * }, * * items: [ // Sub-menu * [ * { * getTitle: (context) => { * return (!context.entity.visible) ? "Show" : "Hide"; * }, * doAction: function (context) { * context.entity.visible = !context.entity.visible; * } * }, * { * getTitle: (context) => { * return (!context.entity.selected) ? "Select" : "Undo Select"; * }, * doAction: function (context) { * context.entity.selected = !context.entity.selected; * } * }, * { * getTitle: (context) => { * return (!context.entity.highlighted) ? "Highlight" : "Undo Highlight"; * }, * doAction: function (context) { * context.entity.highlighted = !context.entity.highlighted; * } * } * ] * ] * } * ] * ] * }); * ```` */ class ContextMenu { /** * Creates a ````ContextMenu````. * * The ````ContextMenu```` will be initially hidden. * * @param {Object} [cfg] ````ContextMenu```` configuration. * @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. * @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}. * @param {Boolean} [cfg.enabled=true] Whether this ````ContextMenu```` is initially enabled. {@link ContextMenu#show} does nothing while this is ````false````. * @param {Boolean} [cfg.hideOnMouseDown=true] Whether this ````ContextMenu```` automatically hides whenever we mouse-down or tap anywhere in the page. * @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. * @param {Node | undefined} [cfg.parentNode] Optional reference to an existing DOM Node (e.g. ShadowRoot), to which the menu's HTML element will be appended, defaults to ````document.body````. */ constructor(cfg = {}) { this._id = idMap.addItem(); this._context = null; this._enabled = false; // True when the ContextMenu is enabled this._itemsCfg = []; // Items as given as configs this._rootMenu = null; // The root Menu in the tree this._menuList = []; // List of Menus this._menuMap = {}; // Menus mapped to their IDs this._itemList = []; // List of Items this._itemMap = {}; // Items mapped to their IDs this._shown = false; // True when the ContextMenu is visible this._nextId = 0; this._parentNode = cfg.parentNode || document.body; this._offsetParent = (this._parentNode instanceof ShadowRoot) ? this._parentNode.host : this._parentNode; /** * Subscriptions to events fired at this ContextMenu. * @private */ this._eventSubs = {}; if (cfg.hideOnMouseDown !== false) { this._parentNode.addEventListener("mousedown", (event) => { if (!event.target.classList.contains("xeokit-context-menu-item")) { this.hide(); } }); this._parentNode.addEventListener("touchstart", this._canvasTouchStartHandler = (event) => { if (!event.target.classList.contains("xeokit-context-menu-item")) { this.hide(); } }); } if (cfg.items) { this.items = cfg.items; } this._hideOnAction = (cfg.hideOnAction !== false); this.context = cfg.context; this.enabled = cfg.enabled !== false; this.hide(); } /** Subscribes to an event fired at this ````ContextMenu````. @param {String} event The event @param {Function} callback Callback fired on the event */ on(event, callback) { let subs = this._eventSubs[event]; if (!subs) { subs = []; this._eventSubs[event] = subs; } subs.push(callback); } /** Fires an event at this ````ContextMenu````. @param {String} event The event type name @param {Object} value The event parameters */ fire(event, value) { const subs = this._eventSubs[event]; if (subs) { for (let i = 0, len = subs.length; i < len; i++) { subs[i](value); } } } /** * Sets the ````ContextMenu```` items. * * These can be updated dynamically at any time. * * See class documentation for an example. * * @type {Object[]} */ set items(itemsCfg) { this._clear(); this._itemsCfg = itemsCfg || []; this._parseItems(itemsCfg); this._createUI(); } /** * Gets the ````ContextMenu```` items. * * @type {Object[]} */ get items() { return this._itemsCfg; } /** * Sets whether this ````ContextMenu```` is enabled. * * Hides the menu when disabling. * * @type {Boolean} */ set enabled(enabled) { enabled = (!!enabled); if (enabled === this._enabled) { return; } this._enabled = enabled; if (!this._enabled) { this.hide(); } } /** * Gets whether this ````ContextMenu```` is enabled. * * {@link ContextMenu#show} does nothing while this is ````false````. * * @type {Boolean} */ get enabled() { return this._enabled; } /** * Sets the ````ContextMenu```` context. * * The context can be any object that you need to be provides to the callbacks configured on {@link ContextMenu#items}. * * This must be set before calling {@link ContextMenu#show}. * * @type {Object} */ set context(context) { this._context = context; } /** * Gets the ````ContextMenu```` context. * * @type {Object} */ get context() { return this._context; } /** * Shows this ````ContextMenu```` at the given page coordinates. * * Does nothing when {@link ContextMenu#enabled} is ````false````. * * Logs error to console and does nothing if {@link ContextMenu#context} has not been set. * * Fires a "shown" event when shown. * * @param {Number} pageX Page X-coordinate. * @param {Number} pageY Page Y-coordinate. */ show(pageX, pageY) { if (!this._context) { console.error("ContextMenu cannot be shown without a context - set context first"); return; } if (!this._enabled) { return; } if (this._shown) { return; } this._hideAllMenus(); this._updateItemsTitles(); this._updateItemsEnabledStatus(); this._showMenu(this._rootMenu.id, pageX, pageY); this._updateSubMenuInfo(); this._shown = true; this.fire("shown", {}); } /** * Gets whether this ````ContextMenu```` is currently shown or not. * * @returns {Boolean} Whether this ````ContextMenu```` is shown. */ get shown() { return this._shown; } /** * Hides this ````ContextMenu````. * * Fires a "hidden" event when hidden. */ hide() { if (!this._enabled) { return; } if (!this._shown) { return; } this._hideAllMenus(); this._shown = false; this.fire("hidden", {}); } /** * Destroys this ````ContextMenu````. */ destroy() { this._context = null; this._clear(); if (this._id !== null) { idMap.removeItem(this._id); this._id = null; } } _clear() { // Destroys DOM elements, clears menu data for (let i = 0, len = this._menuList.length; i < len; i++) { const menu = this._menuList[i]; const menuElement = menu.menuElement; menuElement.remove(); } this._itemsCfg = []; this._rootMenu = null; this._menuList = []; this._menuMap = {}; this._itemList = []; this._itemMap = {}; } _parseItems(itemsCfg) { // Parses "items" config into menu data const visitItems = (itemsCfg) => { const menuId = this._getNextId(); const menu = new Menu(menuId); for (let i = 0, len = itemsCfg.length; i < len; i++) { const itemsGroupCfg = itemsCfg[i]; const group = new Group(); menu.groups.push(group); for (let j = 0, lenj = itemsGroupCfg.length; j < lenj; j++) { const itemCfg = itemsGroupCfg[j]; const subItemsCfg = itemCfg.items; const hasSubItems = (subItemsCfg && (subItemsCfg.length > 0)); const itemId = this._getNextId(); const getTitle = itemCfg.getTitle || (() => { return (itemCfg.title || ""); }); const doAction = itemCfg.doAction || itemCfg.callback || (() => { }); const getEnabled = itemCfg.getEnabled || (() => { return true; }); const getShown = itemCfg.getShown || (() => { return true; }); const item = new Item(itemId, getTitle, doAction, getEnabled, getShown); item.parentMenu = menu; group.items.push(item); if (hasSubItems) { const subMenu = visitItems(subItemsCfg); item.subMenu = subMenu; subMenu.parentItem = item; } this._itemList.push(item); this._itemMap[item.id] = item; } } this._menuList.push(menu); this._menuMap[menu.id] = menu; return menu; }; this._rootMenu = visitItems(itemsCfg); } _getNextId() { // Returns a unique ID return "ContextMenu_" + this._id + "_" + this._nextId++; // Start ID with alpha chars to make a valid DOM element selector } _createUI() { // Builds DOM elements for the entire menu tree const visitMenu = (menu) => { this._createMenuUI(menu); const groups = menu.groups; for (let i = 0, len = groups.length; i < len; i++) { const group = groups[i]; const groupItems = group.items; for (let j = 0, lenj = groupItems.length; j < lenj; j++) { const item = groupItems[j]; const subMenu = item.subMenu; if (subMenu) { visitMenu(subMenu); } } } }; visitMenu(this._rootMenu); } _createMenuUI(menu) { // Builds DOM elements for a menu const groups = menu.groups; const html = []; const menuElement= document.createElement("div"); menuElement.classList.add("xeokit-context-menu", menu.id); menuElement.style.zIndex = 300000; menuElement.style.position = "absolute"; html.push('<ul>'); if (groups) { for (let i = 0, len = groups.length; i < len; i++) { const group = groups[i]; const groupIdx = i; const groupLen = len; const groupItems = group.items; if (groupItems) { for (let j = 0, lenj = groupItems.length; j < lenj; j++) { const item = groupItems[j]; const itemSubMenu = item.subMenu; const actionTitle = item.title || ""; if (itemSubMenu) { html.push( '<li id="' + item.id + '" class="xeokit-context-menu-item xeokit-context-menu-submenu">' + actionTitle + '</li>'); if (!((groupIdx === groupLen - 1) || (j < lenj - 1))) { html.push( '<li id="' + item.id + '" class="xeokit-context-menu-item-separator"></li>' ); } } else { html.push( '<li id="' + item.id + '" class="xeokit-context-menu-item">' + actionTitle + '</li>'); if (!((groupIdx === groupLen - 1) || (j < lenj - 1))) { html.push( '<li id="' + item.id + '" class="xeokit-context-menu-item-separator"></li>' ); } } } } } } html.push('</ul>'); const htmlString = html.join(""); menuElement.innerHTML = htmlString; this._parentNode.appendChild(menuElement); menu.menuElement = menuElement; menuElement.style["border-radius"] = 4 + "px"; menuElement.style.display = 'none'; menuElement.style["z-index"] = 300000; menuElement.style.background = "white"; menuElement.style.border = "1px solid black"; menuElement.style["box-shadow"] = "0 4px 5px 0 gray"; menuElement.oncontextmenu = (e) => { e.preventDefault(); }; // Bind event handlers const self = this; let lastSubMenu = null; if (groups) { for (let i = 0, len = groups.length; i < len; i++) { const group = groups[i]; const groupItems = group.items; if (groupItems) { for (let j = 0, lenj = groupItems.length; j < lenj; j++) { const item = groupItems[j]; const itemSubMenu = item.subMenu; item.itemElement = this._parentNode.querySelector(`#${item.id}`); if (!item.itemElement) { console.error("ContextMenu item element not found: " + item.id); continue; } item.itemElement.addEventListener("mouseenter", (event) => { event.preventDefault(); const subMenu = item.subMenu; if (!subMenu) { if (lastSubMenu) { self._hideMenu(lastSubMenu.id); lastSubMenu = null; } return; } if (lastSubMenu && (lastSubMenu.id !== subMenu.id)) { self._hideMenu(lastSubMenu.id); lastSubMenu = null; } if (item.enabled === false) { return; } const itemElement = item.itemElement; const subMenuElement = subMenu.menuElement; const itemRect = itemElement.getBoundingClientRect(); subMenuElement.getBoundingClientRect(); const offsetRect = self._offsetParent.getBoundingClientRect(); const subMenuWidth = 200; // TODO const showOnRight = (itemRect.right + subMenuWidth) < offsetRect.right; const showOnLeft = (itemRect.left - subMenuWidth) > offsetRect.left; if(showOnRight) self._showMenu(subMenu.id, itemRect.right + window.scrollX - 5, itemRect.top + window.scrollY - 1); else if (showOnLeft) self._showMenu(subMenu.id, itemRect.left - subMenuWidth + window.scrollX, itemRect.top + window.scrollY - 1); else { const spaceOnLeft = itemRect.left - offsetRect.left, spaceOnRight = offsetRect.right - itemRect.right; if(spaceOnRight > spaceOnLeft) self._showMenu(subMenu.id, itemRect.right - 5 - (subMenuWidth - spaceOnRight), itemRect.top + window.scrollY - 1); else self._showMenu(subMenu.id, itemRect.left - spaceOnLeft, itemRect.top + window.scrollY - 1); } lastSubMenu = subMenu; }); if (!itemSubMenu) { // Item without sub-menu // clicking item fires the item's action callback item.itemElement.addEventListener("click", (event) => { event.preventDefault(); if (!self._context) { return; } if (item.enabled === false) { return; } if (item.doAction) { item.doAction(self._context); } if (this._hideOnAction) { self.hide(); } else { self._updateItemsTitles(); self._updateItemsEnabledStatus(); } }); item.itemElement.addEventListener("mouseup", (event) => { if (event.which !== 3) { return; } event.preventDefault(); if (!self._context) { return; } if (item.enabled === false) { return; } if (item.doAction) { item.doAction(self._context); } if (this._hideOnAction) { self.hide(); } else { self._updateItemsTitles(); self._updateItemsEnabledStatus(); } }); item.itemElement.addEventListener("mouseenter", (event) => { event.preventDefault(); if (item.enabled === false) { return; } if (item.doHover) { item.doHover(self._context); } }); } } } } } } _updateItemsTitles() { // Dynamically updates the title of each Item to the result of Item#getTitle() if (!this._context) { return; } for (let i = 0, len = this._itemList.length; i < len; i++) { const item = this._itemList[i]; const itemElement = item.itemElement; if (!itemElement) { continue; } const getShown = item.getShown; if (!getShown || !getShown(this._context)) { continue; } const title = item.getTitle(this._context); if (item.subMenu) { itemElement.innerText = title; } else { itemElement.innerText = title; } } } _updateItemsEnabledStatus() { // Enables or disables each Item, depending on the result of Item#getEnabled() if (!this._context) { return; } for (let i = 0, len = this._itemList.length; i < len; i++) { const item = this._itemList[i]; const itemElement = item.itemElement; if (!itemElement) { continue; } const getEnabled = item.getEnabled; if (!getEnabled) { continue; } const getShown = item.getShown; if (!getShown) { continue; } const shown = getShown(this._context); item.shown = shown; if (!shown) { itemElement.style.display = "none"; continue; } else { itemElement.style.display = ""; } const enabled = getEnabled(this._context); item.enabled = enabled; if (!enabled) { itemElement.classList.add("disabled"); } else { itemElement.classList.remove("disabled"); } } } _updateSubMenuInfo() { if (!this._context) return; let itemElement, itemRect, subMenuElement, initialStyles, showOnLeft, subMenuWidth; this._itemList.forEach((item) => { if (item.subMenu) { itemElement = item.itemElement; itemRect = itemElement.getBoundingClientRect(); subMenuElement = item.subMenu.menuElement; initialStyles = { visibility: subMenuElement.style.visibility, display: subMenuElement.style.display, }; subMenuElement.style.display = "block"; subMenuElement.style.visibility = "hidden"; subMenuWidth = item.subMenu.menuElement.getBoundingClientRect().width; subMenuElement.style.visibility = initialStyles.visibility; subMenuElement.style.display = initialStyles.display; showOnLeft = ((itemRect.right + subMenuWidth) > window.innerWidth); itemElement.setAttribute("data-submenuposition", showOnLeft ? "left" : "right"); } }); } _showMenu(menuId, pageX, pageY) { // Shows the given menu, at the specified page coordinates const menu = this._menuMap[menuId]; if (!menu) { console.error("Menu not found: " + menuId); return; } if (menu.shown) { return; } const menuElement = menu.menuElement; if (menuElement) { this._showMenuElement(menuElement, pageX, pageY); menu.shown = true; } } _hideMenu(menuId) { // Hides the given menu const menu = this._menuMap[menuId]; if (!menu) { console.error("Menu not found: " + menuId); return; } if (!menu.shown) { return; } const menuElement = menu.menuElement; if (menuElement) { this._hideMenuElement(menuElement); menu.shown = false; } } _hideAllMenus() { for (let i = 0, len = this._menuList.length; i < len; i++) { const menu = this._menuList[i]; this._hideMenu(menu.id); } } _showMenuElement(menuElement, pageX, pageY) { // Shows the given menu element, at the specified page coordinates menuElement.style.display = 'block'; const menuHeight = menuElement.offsetHeight; const menuWidth = menuElement.offsetWidth; const offsetRect = this._offsetParent.getBoundingClientRect(); const bottomContainerBorder = offsetRect.bottom + window.scrollY; const rightContainerBorder = offsetRect.right + window.scrollX; if ((pageY + menuHeight) > bottomContainerBorder) { pageY = bottomContainerBorder- menuHeight; } if ((pageX + menuWidth) > rightContainerBorder) { pageX = rightContainerBorder - menuWidth; } menuElement.style.left = pageX - offsetRect.left - window.scrollX + 'px'; menuElement.style.top = pageY - offsetRect.top - window.scrollY + 'px'; } _hideMenuElement(menuElement) { menuElement.style.display = 'none'; } } /** * A PointerLens shows a magnified view of a {@link Viewer}'s canvas, centered at the position of the * mouse or touch pointer. * * This component is used by {@link DistanceMeasurementsControl} and {@link AngleMeasurementsControl} * to help position the pointer when snap-to-vertex or snap-toedge is enabled. * * [[Run an example](https://xeokit.github.io/xeokit-sdk/examples/measurement/#distance_createWithMouse_snapping)] * * ````JavaScript * * import {Viewer, XKTLoaderPlugin, AngleMeasurementsPlugin, AngleMeasurementsMouseControl, PointerLens} from "../../dist/xeokit-sdk.es.js"; * * const viewer = new Viewer({ * canvasId: "myCanvas", * dtxEnabled: true * }); * * viewer.camera.eye = [-3.93, 2.85, 27.01]; * viewer.camera.look = [4.40, 3.72, 8.89]; * viewer.camera.up = [-0.01, 0.99, 0.039]; * * const xktLoader = new XKTLoaderPlugin(viewer); * * const sceneModel = xktLoader.load({ * id: "myModel", * src: "../../assets/models/xkt/v10/glTF-Embedded/Duplex_A_20110505.glTFEmbedded.xkt", * edges: true * }); * * const angleMeasurements = new AngleMeasurementsPlugin(viewer); * * const angleMeasurementsMouseControl = new AngleMeasurementsMouseControl(angleMeasurements, { * pointerLens : new PointerLens(viewer, { * zoomFactor: 2 * }) * }) * * angleMeasurementsMouseControl.activate(); * ```` */ class PointerLens { /** * Constructs a new PointerLens. * @param viewer The Viewer * @param [cfg] PointerLens configuration. * @param [cfg.active=true] Whether PointerLens is active. The PointerLens can only be shown when this is `true` (default). */ constructor(viewer, cfg={}) { this.viewer = viewer; this.scene = this.viewer.scene; this._lensCursorDiv = document.createElement('div'); this._lensParams = { canvasSize: 300, cursorBorder: 2, cursorSize: 10 }; this._lensCursorDiv.style.borderRadius = "50%"; this._lensCursorDiv.style.width = this._lensParams.cursorSize + "px"; this._lensCursorDiv.style.height = this._lensParams.cursorSize + "px"; this._lensCursorDiv.style.zIndex = "100000"; this._lensCursorDiv.style.position = "absolute"; this._lensCursorDiv.style.pointerEvents = "none"; this._lensContainer = document.createElement('div'); this._lensContainerId = cfg.containerId || 'xeokit-lens'; this._lensContainer.setAttribute("id", this._lensContainerId); this._lensContainer.style.border = "1px solid black"; this._lensContainer.style.background = "white"; // this._lensContainer.style.opacity = "0"; this._lensContainer.style.borderRadius = "50%"; this._lensContainer.style.width = this._lensParams.canvasSize + "px"; this._lensContainer.style.height = this._lensParams.canvasSize + "px"; this._lensContainer.style.zIndex = "15000"; this._lensContainer.style.position = "absolute"; this._lensContainer.style.pointerEvents = "none"; this._lensContainer.style.visibility = "hidden"; this._lensCanvas = document.createElement('canvas'); this._lensCanvas.id = `${this._lensContainerId}-canvas`; // this._lensCanvas.style.background = "darkblue"; this._lensCanvas.style.borderRadius = "50%"; this._lensCanvas.style.width = this._lensParams.canvasSize + "px"; this._lensCanvas.style.height = this._lensParams.canvasSize + "px"; this._lensCanvas.style.zIndex = "15000"; this._lensCanvas.style.pointerEvents = "none"; document.body.appendChild(this._lensContainer); this._lensContainer.appendChild(this._lensCanvas); this._lensContainer.appendChild(this._lensCursorDiv); this._lensCanvasContext = this._lensCanvas.getContext('2d'); this._canvasElement = this.viewer.scene.canvas.canvas; this._canvasPos = null; this._snappedCanvasPos = null; this._lensPosToggle = cfg.lensPosToggle || true; this._lensPosToggleAmount = cfg.lensPosToggleAmount || 85; this._lensPosMarginLeft = cfg.lensPosMarginLeft || 85; this._lensPosMarginTop = cfg.lensPosMarginTop || 25; this._lensContainer.style.marginTop = `${this._lensPosMarginTop}px`; this._lensContainer.style.marginLeft = `${this._lensPosMarginLeft}px`; this._zoomLevel = cfg.zoomLevel || 2; this._active = (cfg.active !== false); this._visible = false; this.snapped = false; this._onViewerRendering = this.viewer.scene.on("rendering", () => { if (this._active && this._visible) { this.update(); } }); } /** * Updates this PointerLens. */ update() { if (!this._active || !this._visible) { return; } if (!this._canvasPos) { return; } const lensRect = this._lensContainer.getBoundingClientRect(); const canvasRect = this._canvasElement.getBoundingClientRect(); const pointerOnLens = this._canvasPos[0] < lensRect.right && this._canvasPos[0] > lensRect.left && this._canvasPos[1] < lensRect.bottom && this._canvasPos[1] > lensRect.top; this._lensContainer.style.marginLeft = `${this._lensPosMarginLeft}px`; if (pointerOnLens) { if (this._lensPosToggle) { this._lensContainer.style.marginTop = `${canvasRect.bottom - canvasRect.top - this._lensCanvas.height - this._lensPosToggleAmount}px`; } else { this._lensContainer.style.marginTop = `${this._lensPosMarginTop}px`; } this._lensPosToggle = !this._lensPosToggle; } this._lensCanvasContext.clearRect(0, 0, this._lensCanvas.width, this._lensCanvas.height); const size = Math.max(this._lensCanvas.width, this._lensCanvas.height) / this._zoomLevel; this._lensCanvasContext.drawImage( this._canvasElement, // source canvas this._canvasPos[0] - size / 2, // source x (zoom center) this._canvasPos[1] - size / 2, // source y (zoom center) size, // source width size, // source height 0, // destination x 0, // destination y this._lensCanvas.width, // destination width this._lensCanvas.height // destination height ); const middle = this._lensParams.canvasSize / 2 - this._lensParams.cursorSize / 2 - this._lensParams.cursorBorder; const deltaX = this._snappedCanvasPos ? (this._snappedCanvasPos[0] - this._canvasPos[0]) : 0; const deltaY = this._snappedCanvasPos ? (this._snappedCanvasPos[1] - this._canvasPos[1]) : 0; this._lensCursorDiv.style.left = `${middle + deltaX * this._zoomLevel}px`; this._lensCursorDiv.style.top = `${middle + deltaY * this._zoomLevel}px`; } /** * Sets the zoom factor for the lens. * * This is `2` by default. * * @param zoomFactor */ set zoomFactor(zoomFactor) { this._zoomFactor = zoomFactor; this.update(); } /** * Gets the zoom factor for the lens. * * This is `2` by default. * * @returns Number */ get zoomFactor() { return this._zoomFactor; } /** * Sets the canvas central position of the lens. * @param canvasPos */ set canvasPos(canvasPos) { this._canvasPos = canvasPos; this.update(); } /** * Gets the canvas central position of the lens. * @returns {Number[]} */ get canvasPos() { return this._canvasPos; } /** * Sets the canvas coordinates of the pointer. * @param snappedCanvasPos */ set snappedCanvasPos(snappedCanvasPos) { this._snappedCanvasPos = snappedCanvasPos; this.update(); } /** * Gets the canvas coordinates of the snapped pointer. * @returns {Number[]} */ get snappedCanvasPos() { return this._snappedCanvasPos; } /** * Sets if the cursor has snapped to anything. * This is set by plugins. * @param snapped * @private */ set snapped(snapped) { this._snapped = snapped; const [ bg, border ] = snapped ? [ "greenyellow", "green" ] : [ "pink", "red" ]; this._lensCursorDiv.style.background = bg; this._lensCursorDiv.style.border = this._lensParams.cursorBorder + "px solid " + border; } /** * Gets if the cursor has snapped to anything. * This is called by plugins. * @returns {Boolean} * @private */ get snapped() { return this._snapped; } _updateActiveVisible() { this._lensContainer.style.visibility = (this._active && this._visible) ? "visible" : "hidden"; this.update(); } /** * Sets if this PointerLens is active. * @param active */ set active(active) { this._active = active; this._updateActiveVisible(); } /** * Gets if this PointerLens is active. * @returns {Boolean} */ get active() { return this._active; } /** * Sets if this PointerLens is visible. * This is set by plugins. * @param visible * @private */ set visible(visible) { this._visible = visible; this._updateActiveVisible(); } /** * Gets if this PointerLens is visible. * This is called by plugins. * @returns {Boolean} * @private */ get visible() { return this._visible; } /** * Destroys this PointerLens. */ destroy() { if (!this._destroyed) { this.viewer.scene.off(this._onViewerRendering); this._lensContainer.removeChild(this._lensCanvas); document.body.removeChild(this._lensContainer); this._destroyed = true; } } } // Some temporary vars to help avoid garbage collection let doublePrecision = true; let FloatArrayType = doublePrecision ? Float64Array : Float32Array; const tempVec3a$P = new FloatArrayType(3); const tempMat1 = new FloatArrayType(16); const tempMat2 = new FloatArrayType(16); const tempVec4$2 = new FloatArrayType(4); /** * @private */ const math = { setDoublePrecisionEnabled(enable) { doublePrecision = enable; FloatArrayType = doublePrecision ? Float64Array : Float32Array; }, getDoublePrecisionEnabled() { return doublePrecision; }, MIN_DOUBLE: -Number.MAX_SAFE_INTEGER, MAX_DOUBLE: Number.MAX_SAFE_INTEGER, MAX_INT: 10000000, /** * The number of radiians in a degree (0.0174532925). * @property DEGTORAD * @type {Number} */ DEGTORAD: 0.0174532925, /** * The number of degrees in a radian. * @property RADTODEG * @type {Number} */ RADTODEG: 57.295779513, unglobalizeObjectId(modelId, globalId) { const idx = globalId.indexOf("#"); return (idx === modelId.length && globalId.startsWith(modelId)) ? globalId.substring(idx + 1) : globalId; }, globalizeObjectId(modelId, objectId) { return (modelId + "#" + objectId) }, /** * Returns: * - x != 0 => 1/x, * - x == 1 => 1 * * @param {number} x */ safeInv(x) { const retVal = 1 / x; if (isNaN(retVal) || !isFinite(retVal)) { return 1; } return retVal; }, /** * Returns a new, uninitialized two-element vector. * @method vec2 * @param [values] Initial values. * @static * @returns {Number[]} */ vec2(values) { return new FloatArrayType(values || 2); }, /** * Returns a new, uninitialized three-element vector. * @method vec3 * @param [values] Initial values. * @static * @returns {Number[]} */ vec3(values) { return new FloatArrayType(values || 3); }, /** * Returns a new, uninitialized four-element vector. * @method vec4 * @param [values] Initial values. * @static * @returns {Number[]} */ vec4(values) { return new FloatArrayType(values || 4); }, /** * Returns a new, uninitialized 3x3 matrix. * @method mat3 * @param [values] Initial values. * @static * @returns {Number[]} */ mat3(values) { return new FloatArrayType(values || 9); }, /** * Converts a 3x3 matrix to 4x4 * @method mat3ToMat4 * @param mat3 3x3 matrix. * @param mat4 4x4 matrix * @static * @returns {Number[]} */ mat3ToMat4(mat3, mat4 = new FloatArrayType(16)) { mat4[0] = mat3[0]; mat4[1] = mat3[1]; mat4[2] = mat3[2]; mat4[3] = 0; mat4[4] = mat3[3]; mat4[5] = mat3[4]; mat4[6] = mat3[5]; mat4[7] = 0; mat4[8] = mat3[6]; mat4[9] = mat3[7]; mat4[10] = mat3[8]; mat4[11] = 0; mat4[12] = 0; mat4[13] = 0; mat4[14] = 0; mat4[15] = 1; return mat4; }, /** * Returns a new, uninitialized 4x4 matrix. * @method mat4 * @param [values] Initial values. * @static * @returns {Number[]} */ mat4(values) { return new FloatArrayType(values || 16); }, /** * Converts a 4x4 matrix to 3x3 * @method mat4ToMat3 * @param mat4 4x4 matrix. * @param mat3 3x3 matrix * @static * @returns {Number[]} */ mat4ToMat3(mat4, mat3 = new FloatArrayType(9)) { mat3[0] = mat4[0]; mat3[1] = mat4[1]; mat3[2] = mat4[2]; mat3[3] = mat4[4]; mat3[4] = mat4[5]; mat3[5] = mat4[6]; mat3[6] = mat4[8]; mat3[7] = mat4[9]; mat3[8] = mat4[10]; return mat3; }, /** * Converts a list of double-precision values to a list of high-part floats and a list of low-part floats. * @param doubleVals * @param floatValsHigh * @param floatValsLow */ doublesToFloats(doubleVals, floatValsHigh, floatValsLow) {